Compare commits
62
Commits
adf61e43d6
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3b4f534d5 | ||
|
|
293b633a24 | ||
|
|
ae4012adb7 | ||
|
|
6c2a277751 | ||
|
|
a558a2a271 | ||
|
|
3ee90993ed | ||
|
|
b404bb2aff | ||
|
|
3a33e9cae9 | ||
|
|
46de4cbfee | ||
|
|
d7fee801d3 | ||
|
|
4f4c6b7a16 | ||
|
|
5563becad2 | ||
|
|
6301cd5984 | ||
|
|
9b6971095c | ||
|
|
8a6d523062 | ||
|
|
16a1ca02e8 | ||
|
|
0cb1df3bfe | ||
|
|
6f369376ec | ||
|
|
02fa1a228a | ||
|
|
099d5df3e1 | ||
|
|
df98f98669 | ||
|
|
6d58802127 | ||
|
|
165675b131 | ||
|
|
08a4427e31 | ||
|
|
f4460beddd | ||
|
|
70d5c233c0 | ||
|
|
c0d10e9664 | ||
|
|
d16bbf3695 | ||
|
|
83ce73f26b | ||
|
|
31e9c2ee2c | ||
|
|
6b0f2eda22 | ||
|
|
dc1617a2c1 | ||
|
|
f213878a7c | ||
|
|
8d8e523a26 | ||
|
|
8b07f776e5 | ||
|
|
8b46ead72a | ||
|
|
8961cbdfc9 | ||
|
|
794150b11b | ||
|
|
2e74a3ca69 | ||
|
|
e94d88e723 | ||
|
|
097b2ea014 | ||
|
|
a43ac0156f | ||
|
|
534120d3de | ||
|
|
a90aa29478 | ||
|
|
a30a083df0 | ||
|
|
89d3975eb3 | ||
|
|
3f1838cfdd | ||
|
|
dcb8e6eb25 | ||
|
|
3f127f9b62 | ||
|
|
f2d9590058 | ||
|
|
ee6e99965c | ||
|
|
b3e09a3314 | ||
|
|
2fdf33f99b | ||
|
|
092a802959 | ||
|
|
e9eeaf00c3 | ||
|
|
572b98cbcf | ||
|
|
bcfc324426 | ||
|
|
7cea9b7339 | ||
|
|
f3f0b32a48 | ||
|
|
8fe20f6385 | ||
|
|
15c1181aef | ||
|
|
0101cc707c |
@@ -553,7 +553,6 @@ set(APP_SOURCES
|
|||||||
src/ui/windows/console_selection_controller.cpp
|
src/ui/windows/console_selection_controller.cpp
|
||||||
src/ui/windows/console_tab_helpers.cpp
|
src/ui/windows/console_tab_helpers.cpp
|
||||||
src/ui/windows/console_text_layout.cpp
|
src/ui/windows/console_text_layout.cpp
|
||||||
src/ui/windows/settings_window.cpp
|
|
||||||
src/ui/pages/settings_page.cpp
|
src/ui/pages/settings_page.cpp
|
||||||
src/ui/windows/about_dialog.cpp
|
src/ui/windows/about_dialog.cpp
|
||||||
src/ui/windows/faq_dialog.cpp
|
src/ui/windows/faq_dialog.cpp
|
||||||
@@ -704,7 +703,6 @@ set(APP_HEADERS
|
|||||||
src/ui/windows/console_selection_controller.h
|
src/ui/windows/console_selection_controller.h
|
||||||
src/ui/windows/console_tab.h
|
src/ui/windows/console_tab.h
|
||||||
src/ui/windows/console_tab_helpers.h
|
src/ui/windows/console_tab_helpers.h
|
||||||
src/ui/windows/settings_window.h
|
|
||||||
src/ui/windows/about_dialog.h
|
src/ui/windows/about_dialog.h
|
||||||
src/ui/windows/faq_dialog.h
|
src/ui/windows/faq_dialog.h
|
||||||
src/ui/windows/faq_content.h
|
src/ui/windows/faq_content.h
|
||||||
|
|||||||
@@ -1225,6 +1225,8 @@ pill-margin-y = { size = 3.0 }
|
|||||||
pill-rounding = { size = 12.0 }
|
pill-rounding = { size = 12.0 }
|
||||||
padding-x = { size = 12.0 }
|
padding-x = { size = 12.0 }
|
||||||
glass-fill-alpha = { size = 18.0 }
|
glass-fill-alpha = { size = 18.0 }
|
||||||
|
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
|
||||||
|
badge-fill-alpha = { size = 0.35 } # "+N more" badge fill opacity (fraction of the accent alpha)
|
||||||
glass-border-alpha = { size = 35.0 }
|
glass-border-alpha = { size = 35.0 }
|
||||||
progress-bar-height = { size = 2.0 }
|
progress-bar-height = { size = 2.0 }
|
||||||
duration-info = { size = 1.0 }
|
duration-info = { size = 1.0 }
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ for f in "${files[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Content guards (UX-hygiene threads) ──────────────────────────────────────
|
||||||
|
# Low-false-positive regression checks for debt the UX audit cleared, so it can't
|
||||||
|
# silently regrow. Each is deliberately narrow; widen only with an allowlist.
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
[[ "$f" == *.cpp ]] || continue
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
# app_sweep.cpp is the dev screenshot-sweep harness: its fixture strings are never shipped UI.
|
||||||
|
[[ "$(basename "$f")" == "app_sweep.cpp" ]] && continue
|
||||||
|
|
||||||
|
# (1) Tactile button sized with a raw multi-digit pixel literal that ignores HiDPI.
|
||||||
|
# Real sizes multiply by ui::Layout::dpiScale() / dp / hScale, or come from schema.
|
||||||
|
if hits=$(grep -nE 'Tactile(Small)?Button\([^;]*ImVec2\([1-9][0-9]+(\.[0-9]+f?)?,' "$f" \
|
||||||
|
| grep -vE 'dpiScale|\* *dp\b|\* *hdpi|hScale|schema|Scale\('); then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "✗ unscaled button size (multiply the px by ui::Layout::dpiScale()): $f:$line" >&2
|
||||||
|
fail=1
|
||||||
|
done <<< "$hits"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (2) User-visible migrate/sweep status assigned a bare English literal instead of TR(...).
|
||||||
|
# "__SENTINEL__" comparisons and empty strings are fine.
|
||||||
|
if hits=$(grep -nE '(sweep_status_|seed_migration_status_)[[:space:]]*=[[:space:]]*"[A-Za-z]' "$f" \
|
||||||
|
| grep -vE 'TR\(|"__'); then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "✗ untranslated status string (route through TR(...)): $f:$line" >&2
|
||||||
|
fail=1
|
||||||
|
done <<< "$hits"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if (( fail )); then
|
if (( fail )); then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
||||||
|
|||||||
+304
-77
@@ -36,11 +36,11 @@
|
|||||||
#include "ui/windows/explorer_tab.h"
|
#include "ui/windows/explorer_tab.h"
|
||||||
#include "ui/windows/seed_display.h"
|
#include "ui/windows/seed_display.h"
|
||||||
#include "ui/windows/market_tab.h"
|
#include "ui/windows/market_tab.h"
|
||||||
#include "ui/windows/settings_window.h"
|
|
||||||
#include "ui/windows/about_dialog.h"
|
#include "ui/windows/about_dialog.h"
|
||||||
#include "ui/windows/faq_dialog.h"
|
#include "ui/windows/faq_dialog.h"
|
||||||
#include "embedded/IconsMaterialDesign.h"
|
#include "embedded/IconsMaterialDesign.h"
|
||||||
#include "ui/windows/key_export_dialog.h"
|
#include "ui/windows/key_export_dialog.h"
|
||||||
|
#include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers
|
||||||
#include "ui/windows/transaction_details_dialog.h"
|
#include "ui/windows/transaction_details_dialog.h"
|
||||||
#include "ui/windows/qr_popup_dialog.h"
|
#include "ui/windows/qr_popup_dialog.h"
|
||||||
#include "ui/windows/validate_address_dialog.h"
|
#include "ui/windows/validate_address_dialog.h"
|
||||||
@@ -144,6 +144,28 @@ void App::wipeSecrets()
|
|||||||
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
||||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
||||||
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
|
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
|
||||||
|
ui::scrubImGuiInputTextState(); // zero ImGui's retained copies of whatever secret was last typed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub + close any secret that is currently ON SCREEN (revealed seed phrase, migration seed, exported
|
||||||
|
// key) when the wallet locks — locking shouldn't leave a phrase/key visible or resident. Called on every
|
||||||
|
// lock transition (manual, auto, lite). Separate from wipeSecrets(), which is the exit-path scrub.
|
||||||
|
void App::wipeDisplayedSecrets()
|
||||||
|
{
|
||||||
|
if (!seed_backup_phrase_.empty()) {
|
||||||
|
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
|
||||||
|
seed_backup_phrase_.clear();
|
||||||
|
}
|
||||||
|
if (!seed_migration_seed_.empty()) {
|
||||||
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||||
|
seed_migration_seed_.clear();
|
||||||
|
}
|
||||||
|
seed_backup_fetch_started_ = false;
|
||||||
|
show_seed_backup_ = false;
|
||||||
|
sodium_memzero(export_result_, sizeof(export_result_));
|
||||||
|
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
|
||||||
|
console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console
|
||||||
|
ui::scrubImGuiInputTextState(); // zero ImGui's retained copy of the last-typed passphrase/PIN
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
||||||
@@ -943,6 +965,9 @@ void App::update()
|
|||||||
lite_wallet_->pumpAsyncOpen();
|
lite_wallet_->pumpAsyncOpen();
|
||||||
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
||||||
lite_wallet_->pumpLifecycleResult();
|
lite_wallet_->pumpLifecycleResult();
|
||||||
|
// A create/restore whose passphrase encryption failed left the wallet UNENCRYPTED (previously log-only): say so.
|
||||||
|
if (lite_wallet_->takeEncryptionFailureNotice())
|
||||||
|
ui::Notifications::instance().error(TR("lite_encrypt_failed_unencrypted"), 15.0f);
|
||||||
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
||||||
lite_wallet_->pumpWalletOps();
|
lite_wallet_->pumpWalletOps();
|
||||||
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
||||||
@@ -1028,7 +1053,11 @@ void App::update()
|
|||||||
case LiteOpKind::None:
|
case LiteOpKind::None:
|
||||||
default:
|
default:
|
||||||
if (lite_send_callback_) {
|
if (lite_send_callback_) {
|
||||||
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
// A transport-style failure may mean the server accepted the tx but the reply was
|
||||||
|
// lost: tag it so the Send tab says "check History" instead of inviting a resend.
|
||||||
|
const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error))
|
||||||
|
? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error;
|
||||||
|
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr);
|
||||||
lite_send_callback_ = nullptr;
|
lite_send_callback_ = nullptr;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1233,14 +1262,18 @@ void App::update()
|
|||||||
if (xmrig_manager_ && state_.pool_mining.xmrig_running
|
if (xmrig_manager_ && state_.pool_mining.xmrig_running
|
||||||
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
|
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
|
||||||
state_.pool_mining.xmrig_running = false;
|
state_.pool_mining.xmrig_running = false;
|
||||||
|
state_.pool_mining.running_since = 0;
|
||||||
|
state_.pool_mining.last_error.clear();
|
||||||
state_.pool_mining.hashrate_10s = 0.0;
|
state_.pool_mining.hashrate_10s = 0.0;
|
||||||
state_.pool_mining.hashrate_60s = 0.0;
|
state_.pool_mining.hashrate_60s = 0.0;
|
||||||
state_.pool_mining.hashrate_15m = 0.0;
|
state_.pool_mining.hashrate_15m = 0.0;
|
||||||
pool_starting_.store(false, std::memory_order_relaxed);
|
pool_starting_.store(false, std::memory_order_relaxed);
|
||||||
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
|
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
|
||||||
const std::string err = xmrig_manager_->getLastError();
|
const std::string err = xmrig_manager_->getLastError();
|
||||||
ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly")
|
const std::string mmsg = err.empty() ? std::string(TR("appx_miner_stopped_unexpectedly"))
|
||||||
: (std::string(TR("appx_miner_stopped_prefix")) + err));
|
: (std::string(TR("appx_miner_stopped_prefix")) + err);
|
||||||
|
setMiningError(mmsg); // persist: a miner that crashed/was killed otherwise just "did nothing"
|
||||||
|
ui::Notifications::instance().error(mmsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll xmrig stats every ~2 seconds (use a simple toggle)
|
// Poll xmrig stats every ~2 seconds (use a simple toggle)
|
||||||
@@ -1273,6 +1306,19 @@ void App::update()
|
|||||||
ps.threads_active = xs.threads_active;
|
ps.threads_active = xs.threads_active;
|
||||||
ps.pool_hashrate = xs.pool_hashrate;
|
ps.pool_hashrate = xs.pool_hashrate;
|
||||||
ps.log_lines = xmrig_manager_->getRecentLines(30);
|
ps.log_lines = xmrig_manager_->getRecentLines(30);
|
||||||
|
if (ps.running_since == 0) ps.running_since = std::time(nullptr);
|
||||||
|
// While not yet connected, surface the newest error-looking log line so "Connecting" can flip
|
||||||
|
// to a real "can't reach pool" after a timeout instead of sitting on red forever.
|
||||||
|
ps.last_error.clear();
|
||||||
|
if (!ps.connected) {
|
||||||
|
for (auto it = ps.log_lines.rbegin(); it != ps.log_lines.rend(); ++it) {
|
||||||
|
std::string l = *it;
|
||||||
|
std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c){ return (char)std::tolower(c); });
|
||||||
|
if (l.find("error") != std::string::npos || l.find("refused") != std::string::npos ||
|
||||||
|
l.find("timed out") != std::string::npos || l.find("unreachable") != std::string::npos ||
|
||||||
|
l.find("dns") != std::string::npos) { ps.last_error = *it; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record hashrate sample for the chart
|
// Record hashrate sample for the chart
|
||||||
ps.hashrate_history.push_back(ps.hashrate_10s);
|
ps.hashrate_history.push_back(ps.hashrate_10s);
|
||||||
@@ -1283,6 +1329,8 @@ void App::update()
|
|||||||
}
|
}
|
||||||
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
|
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
|
||||||
state_.pool_mining.xmrig_running = false;
|
state_.pool_mining.xmrig_running = false;
|
||||||
|
state_.pool_mining.running_since = 0;
|
||||||
|
state_.pool_mining.last_error.clear();
|
||||||
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
|
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1944,6 +1992,12 @@ void App::render()
|
|||||||
}
|
}
|
||||||
settings_->setWizardCompleted(true);
|
settings_->setWizardCompleted(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
|
// Soften the abrupt handoff: tell the user the node is starting + will keep syncing in the
|
||||||
|
// background, instead of dropping them onto a busy/blank main window with no explanation.
|
||||||
|
if (!state_.connected)
|
||||||
|
ui::Notifications::instance().info(
|
||||||
|
settings_->getEncryptionPending() ? TR("wiz_handoff_encrypting") : TR("wiz_handoff_starting"),
|
||||||
|
10.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process deferred encryption from wizard (runs in background)
|
// Process deferred encryption from wizard (runs in background)
|
||||||
@@ -2368,6 +2422,8 @@ void App::render()
|
|||||||
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||||
else
|
else
|
||||||
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
||||||
|
// One-time: route the console's "Copy secret" through the auto-clearing clipboard.
|
||||||
|
console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); });
|
||||||
}
|
}
|
||||||
console_tab_.render(*console_exec_);
|
console_tab_.render(*console_exec_);
|
||||||
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
||||||
@@ -2509,9 +2565,6 @@ void App::render()
|
|||||||
// content area, status bar, and every other widget in this window.
|
// content area, status bar, and every other widget in this window.
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
PERF_BEGIN(_perfDialogs);
|
PERF_BEGIN(_perfDialogs);
|
||||||
if (show_settings_) {
|
|
||||||
ui::RenderSettingsWindow(this, &show_settings_);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (show_about_) {
|
if (show_about_) {
|
||||||
ui::RenderAboutDialog(this, &show_about_);
|
ui::RenderAboutDialog(this, &show_about_);
|
||||||
@@ -2530,6 +2583,10 @@ void App::render()
|
|||||||
renderImportKeyDialog();
|
renderImportKeyDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (show_import_wallet_file_) {
|
||||||
|
renderImportWalletFileDialog();
|
||||||
|
}
|
||||||
|
|
||||||
if (show_export_key_) {
|
if (show_export_key_) {
|
||||||
renderExportKeyDialog();
|
renderExportKeyDialog();
|
||||||
}
|
}
|
||||||
@@ -2662,6 +2719,11 @@ void App::renderNodeStatusBanner()
|
|||||||
const NodeBusyText busy = nodeBusyText();
|
const NodeBusyText busy = nodeBusyText();
|
||||||
in.node_busy = busy.busy;
|
in.node_busy = busy.busy;
|
||||||
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
||||||
|
in.auth_failed = rpc_auth_failed_;
|
||||||
|
// Corrected trigger: ONLY a degraded wallet (wallet_degraded_ — z_getnewaddress fails, can't shield) that
|
||||||
|
// actually holds transparent funds. A merely-non-mnemonic but functional wallet has wallet_degraded_ ==
|
||||||
|
// false and is never flagged here. Full-node only (in.lite returns early regardless).
|
||||||
|
in.legacy_funds_stuck = !isLiteBuild() && wallet_degraded_ && state_.transparentBalance > 0.0001;
|
||||||
|
|
||||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||||
if (!banner.show) return;
|
if (!banner.show) return;
|
||||||
@@ -2698,6 +2760,10 @@ void App::renderNodeStatusBanner()
|
|||||||
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
case ui::NodeBannerReason::NodeBusy:
|
case ui::NodeBannerReason::NodeBusy:
|
||||||
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
||||||
|
case ui::NodeBannerReason::AuthFailed:
|
||||||
|
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
||||||
|
case ui::NodeBannerReason::LegacyFundsStuck:
|
||||||
|
title = TR("node_banner_legacy_title"); icon = ICON_MD_KEY; break;
|
||||||
case ui::NodeBannerReason::FullNodeOffline:
|
case ui::NodeBannerReason::FullNodeOffline:
|
||||||
default:
|
default:
|
||||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
@@ -2706,13 +2772,17 @@ void App::renderNodeStatusBanner()
|
|||||||
const char* actionLabel = nullptr;
|
const char* actionLabel = nullptr;
|
||||||
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
||||||
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
||||||
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) actionLabel = TR("node_banner_recover");
|
||||||
|
|
||||||
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
||||||
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
||||||
// translated, actionable hint instead; every other reason keeps its live status/error text.
|
// translated, actionable hint instead; every other reason keeps its live status/error text.
|
||||||
const std::string detailText = (banner.reason == ui::NodeBannerReason::NoPeers)
|
std::string detailText;
|
||||||
? std::string(TR("node_banner_no_peers_detail"))
|
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
||||||
: banner.detail;
|
else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail");
|
||||||
|
else if (banner.reason == ui::NodeBannerReason::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail");
|
||||||
|
else detailText = banner.detail;
|
||||||
|
|
||||||
const float padX = ui::Layout::spacingLg();
|
const float padX = ui::Layout::spacingLg();
|
||||||
|
|
||||||
@@ -2801,6 +2871,12 @@ void App::renderNodeStatusBanner()
|
|||||||
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
||||||
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
||||||
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog();
|
||||||
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
||||||
|
const std::string conf = rpc::Connection::getDefaultConfPath();
|
||||||
|
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
||||||
|
/*createIfMissing=*/false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3050,50 +3126,28 @@ void App::renderStatusBar()
|
|||||||
} else if (rpc_busy_gate_->busy()) {
|
} else if (rpc_busy_gate_->busy()) {
|
||||||
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
||||||
|
} else if (isLiteBuild() && !state_.sync.lite_error.empty()) {
|
||||||
|
// Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'.
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"),
|
||||||
|
state_.sync.lite_error.c_str());
|
||||||
} else if (state_.sync.syncing) {
|
} else if (state_.sync.syncing) {
|
||||||
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
||||||
int blocksLeft = chainTip - state_.sync.blocks;
|
int blocksLeft = chainTip - state_.sync.blocks;
|
||||||
if (blocksLeft < 0) blocksLeft = 0;
|
if (blocksLeft < 0) blocksLeft = 0;
|
||||||
|
|
||||||
// Calculate sync speed (blocks/sec) using a smoothed rolling average
|
// App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on
|
||||||
static int s_prev_blocks = 0;
|
// connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware.
|
||||||
static double s_prev_time = 0.0;
|
if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset();
|
||||||
static double s_blocks_per_sec = 0.0;
|
sync_eta_.update(state_.sync.blocks, ImGui::GetTime());
|
||||||
double now = ImGui::GetTime();
|
const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning());
|
||||||
if (s_prev_time == 0.0) {
|
const long long etaSec = sync_eta_.etaSeconds(blocksLeft);
|
||||||
// First sample — seed baseline, don't compute rate yet
|
if (etaSec >= 0) {
|
||||||
s_prev_blocks = state_.sync.blocks;
|
|
||||||
s_prev_time = now;
|
|
||||||
} else if (state_.sync.blocks > s_prev_blocks) {
|
|
||||||
double dt = now - s_prev_time;
|
|
||||||
if (dt > 0.5) {
|
|
||||||
double raw = (state_.sync.blocks - s_prev_blocks) / dt;
|
|
||||||
// Exponential smoothing (alpha ~0.3 per update)
|
|
||||||
s_blocks_per_sec = s_blocks_per_sec > 0.0
|
|
||||||
? s_blocks_per_sec * 0.7 + raw * 0.3
|
|
||||||
: raw;
|
|
||||||
s_prev_blocks = state_.sync.blocks;
|
|
||||||
s_prev_time = now;
|
|
||||||
}
|
|
||||||
} else if (now - s_prev_time > 10.0) {
|
|
||||||
// No new blocks for 10s — decay the displayed rate
|
|
||||||
s_blocks_per_sec *= 0.5;
|
|
||||||
s_prev_time = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s_blocks_per_sec > 0.1) {
|
|
||||||
int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
|
|
||||||
char eta[32];
|
char eta[32];
|
||||||
if (eta_sec >= 3600)
|
util::formatEta(etaSec, eta, sizeof(eta));
|
||||||
snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
|
ImGui::TextColored(syncCol, TR("sb_syncing_eta"),
|
||||||
else if (eta_sec >= 60)
|
state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta);
|
||||||
snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
|
|
||||||
else
|
|
||||||
snprintf(eta, sizeof(eta), "%ds", eta_sec);
|
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"),
|
|
||||||
state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
|
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"),
|
ImGui::TextColored(syncCol, TR("sb_syncing_basic"),
|
||||||
state_.sync.verification_progress * 100.0, blocksLeft);
|
state_.sync.verification_progress * 100.0, blocksLeft);
|
||||||
}
|
}
|
||||||
} else if (state_.connected && !isLiteBuild()) {
|
} else if (state_.connected && !isLiteBuild()) {
|
||||||
@@ -3312,7 +3366,7 @@ void App::renderStatusBar()
|
|||||||
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
|
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clicked) {
|
if (clicked || notes.consumeHistoryRequest()) { // bell click, or a click on the toast itself
|
||||||
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
|
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
|
||||||
ImGui::OpenPopup("##AlertHistoryPopup");
|
ImGui::OpenPopup("##AlertHistoryPopup");
|
||||||
}
|
}
|
||||||
@@ -3459,14 +3513,20 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
||||||
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||||
|
|
||||||
|
// Best-effort zero of the seed-word statics (they hold the whole phrase) before they're dropped.
|
||||||
|
auto wipeStr = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
auto wipeWords = [&](std::vector<std::string>& v) { for (auto& w : v) wipeStr(w); v.clear(); };
|
||||||
|
auto wipeChips = [&](std::vector<std::pair<std::string, bool>>& v) {
|
||||||
|
for (auto& c : v) { wipeStr(c.first); c.second = false; } v.clear();
|
||||||
|
};
|
||||||
auto finish = [&]() {
|
auto finish = [&]() {
|
||||||
wallet::secureWipeLiteSecret(seed);
|
wallet::secureWipeLiteSecret(seed);
|
||||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||||
restoreBirthday = 0;
|
restoreBirthday = 0;
|
||||||
restoring = false;
|
restoring = false;
|
||||||
restoreErr.clear();
|
restoreErr.clear();
|
||||||
words.clear();
|
wipeWords(words);
|
||||||
chips.clear();
|
wipeChips(chips);
|
||||||
progress = 0;
|
progress = 0;
|
||||||
step = 0;
|
step = 0;
|
||||||
skipConfirm = false;
|
skipConfirm = false;
|
||||||
@@ -3574,7 +3634,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::Spacing(); ImGui::Spacing();
|
ImGui::Spacing(); ImGui::Spacing();
|
||||||
|
|
||||||
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
|
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
|
||||||
chips.clear();
|
wipeChips(chips);
|
||||||
for (const auto& w : words) chips.emplace_back(w, false);
|
for (const auto& w : words) chips.emplace_back(w, false);
|
||||||
std::mt19937 rng{std::random_device{}()};
|
std::mt19937 rng{std::random_device{}()};
|
||||||
std::shuffle(chips.begin(), chips.end(), rng);
|
std::shuffle(chips.begin(), chips.end(), rng);
|
||||||
@@ -3585,7 +3645,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
skipConfirm = true; // require a second, deliberate click
|
skipConfirm = true; // require a second, deliberate click
|
||||||
} else {
|
} else {
|
||||||
@@ -3648,9 +3708,9 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
if (!verified) ImGui::EndDisabled();
|
if (!verified) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; }
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) { skipConfirm = false; step = 1; }
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
skipConfirm = true; // require a second, deliberate click
|
skipConfirm = true; // require a second, deliberate click
|
||||||
} else {
|
} else {
|
||||||
@@ -3747,7 +3807,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) {
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||||
restoreErr.clear();
|
restoreErr.clear();
|
||||||
step = 0;
|
step = 0;
|
||||||
@@ -3785,12 +3845,13 @@ void App::renderLiteUnlockPrompt()
|
|||||||
const float btnW = 130.0f * dp;
|
const float btnW = 130.0f * dp;
|
||||||
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
||||||
if (doUnlock) {
|
if (doUnlock) {
|
||||||
const bool ok = lite_wallet_->unlockWallet(pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = unlockLiteWallet(std::string(pass), &unlockErr);
|
||||||
sodium_memzero(pass, sizeof(pass));
|
sodium_memzero(pass, sizeof(pass));
|
||||||
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
||||||
else {
|
else {
|
||||||
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
|
wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
|
||||||
ui::Notifications::instance().error(TR("lite_unlock_failed"));
|
ui::Notifications::instance().error(unlockErr);
|
||||||
}
|
}
|
||||||
lite_unlock_prompt_ = false;
|
lite_unlock_prompt_ = false;
|
||||||
ImGui::CloseCurrentPopup();
|
ImGui::CloseCurrentPopup();
|
||||||
@@ -3892,6 +3953,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::renderImportWalletFileDialog()
|
||||||
|
{
|
||||||
|
namespace m = ui::material;
|
||||||
|
float dp = ui::Layout::dpiScale();
|
||||||
|
|
||||||
|
m::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("import_wallet_file_title");
|
||||||
|
ov.p_open = &show_import_wallet_file_;
|
||||||
|
ov.style = m::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 640.0f;
|
||||||
|
ov.idSuffix = "importwalletfile";
|
||||||
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
|
if (ImGui::IsWindowAppearing()) {
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_ok_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
}
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && !import_wallet_file_busy_) show_import_wallet_file_ = false;
|
||||||
|
|
||||||
|
m::DialogWarningHeader(TR("import_wallet_file_note"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (!state_.connected) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextUnformatted(TR("import_wallet_file_path_label"));
|
||||||
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
ImGui::InputText("##importwalletpath", import_wallet_file_path_, sizeof(import_wallet_file_path_));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
if (!import_wallet_file_busy_) {
|
||||||
|
if (m::TactileButton(TR("paste"), ImVec2(104 * dp, 0))) {
|
||||||
|
const char* clip = ImGui::GetClipboardText();
|
||||||
|
if (clip) {
|
||||||
|
std::string t = dragonx::util::trimmedClipboardText(clip);
|
||||||
|
snprintf(import_wallet_file_path_, sizeof(import_wallet_file_path_), "%s", t.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (m::TactileButton(TR("clear"), ImVec2(104 * dp, 0))) import_wallet_file_path_[0] = '\0';
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
if (import_wallet_file_busy_) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s%s",
|
||||||
|
TR("import_wallet_file_importing"), m::LoadingDots());
|
||||||
|
ImGui::Spacing();
|
||||||
|
} else if (import_wallet_file_done_) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(
|
||||||
|
import_wallet_file_ok_ ? m::Success() : m::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", import_wallet_file_status_.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_ || !state_.connected || import_wallet_file_path_[0] == '\0');
|
||||||
|
if (m::TactileButton(TR("import_wallet_file_import_btn"), ImVec2(190 * dp, 0))) {
|
||||||
|
import_wallet_file_busy_ = true;
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
importWalletFile(import_wallet_file_path_, [this](bool ok, const std::string& err) {
|
||||||
|
import_wallet_file_busy_ = false;
|
||||||
|
import_wallet_file_done_ = true;
|
||||||
|
import_wallet_file_ok_ = ok;
|
||||||
|
import_wallet_file_status_ = ok ? std::string(TR("import_wallet_file_ok")) : err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
if (m::TactileButton(TR("close"), ImVec2(110 * dp, 0))) show_import_wallet_file_ = false;
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
m::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
|
||||||
void App::renderImportKeyDialog()
|
void App::renderImportKeyDialog()
|
||||||
{
|
{
|
||||||
namespace m = ui::material;
|
namespace m = ui::material;
|
||||||
@@ -4319,8 +4459,11 @@ void App::renderExportKeyDialog()
|
|||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
||||||
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
||||||
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
||||||
if (m::TactileButton(TR("copy")))
|
if (m::TactileButton(TR("copy"))) {
|
||||||
ImGui::SetClipboardText(export_result_);
|
// A private key is a secret — route through the auto-clearing clipboard path (45s armed wipe
|
||||||
|
// + its own "copied, auto-clears" toast) instead of a plain, permanent clipboard write.
|
||||||
|
copySecretToClipboard(export_result_);
|
||||||
|
}
|
||||||
} else if (export_error_) {
|
} else if (export_error_) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -4704,7 +4847,7 @@ void App::renderSeedBackupDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
|
{ auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_backup_status_.empty()) {
|
if (!seed_backup_status_.empty()) {
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -4759,6 +4902,9 @@ void App::renderSeedMigrationDialog()
|
|||||||
if (!seed_migration_seed_.empty())
|
if (!seed_migration_seed_.empty())
|
||||||
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||||
seed_migration_seed_.clear();
|
seed_migration_seed_.clear();
|
||||||
|
// Disarm the inline confirms so an armed state can never survive a close/reopen.
|
||||||
|
seed_migration_confirm_discard_ = false;
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
};
|
};
|
||||||
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
|
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
|
||||||
|
|
||||||
@@ -4871,7 +5017,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
||||||
{
|
{
|
||||||
ui::material::GlassSectionScope seedPanel;
|
ui::material::GlassSectionScope seedPanel;
|
||||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
|
{ auto words = ui::SplitSeedWords(seed_migration_seed_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
||||||
@@ -4892,11 +5038,15 @@ void App::renderSeedMigrationDialog()
|
|||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
|
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
|
||||||
|
ImGui::Checkbox(TR("mig_understood"), &seed_migration_understood_);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
||||||
ImGui::TextWrapped("%s", TR("mig_step1_done"));
|
ImGui::TextWrapped("%s", TR("mig_step1_done"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
// Continue requires BOTH acknowledgements, and is suppressed while the Discard confirm is armed.
|
||||||
|
const bool ackOk = seed_migration_backed_up_ && seed_migration_understood_;
|
||||||
|
const bool continueDisabled = !ackOk || seed_migration_confirm_discard_;
|
||||||
|
if (continueDisabled) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
|
if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
|
||||||
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
|
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweep;
|
seed_migration_step_ = SeedMigrationStep::Sweep;
|
||||||
@@ -4904,10 +5054,22 @@ void App::renderSeedMigrationDialog()
|
|||||||
seed_migration_nofunds_confirmed_ = false;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
refreshSeedMigrationBalance();
|
refreshSeedMigrationBalance();
|
||||||
}
|
}
|
||||||
if (!seed_migration_backed_up_) ImGui::EndDisabled();
|
if (continueDisabled) ImGui::EndDisabled();
|
||||||
|
if (!ackOk && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
ui::material::Tooltip("%s", TR("mig_ack_required"));
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0))) {
|
// Discard now takes a second click: first click arms a confirm, since it deletes the new wallet.
|
||||||
// Throw away the isolated new wallet + clear the pending state.
|
if (!seed_migration_confirm_discard_) {
|
||||||
|
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0)))
|
||||||
|
seed_migration_confirm_discard_ = true;
|
||||||
|
}
|
||||||
|
if (seed_migration_confirm_discard_) {
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", TR("mig_discard_confirm"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (ui::material::TactileButton(TR("mig_discard_yes"), ImVec2(160 * dp, 0))) {
|
||||||
|
// Throw away the isolated new wallet + clear the pending state. (body unchanged)
|
||||||
if (!seed_migration_temp_dir_.empty()) {
|
if (!seed_migration_temp_dir_.empty()) {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
|
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
|
||||||
@@ -4920,8 +5082,13 @@ void App::renderSeedMigrationDialog()
|
|||||||
}
|
}
|
||||||
seed_migration_temp_dir_.clear();
|
seed_migration_temp_dir_.clear();
|
||||||
seed_migration_dest_.clear();
|
seed_migration_dest_.clear();
|
||||||
|
seed_migration_confirm_discard_ = false;
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
|
seed_migration_confirm_discard_ = false;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4972,7 +5139,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // fresh minconf-0, sync-aware balance check before the swap
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
@@ -4996,17 +5163,51 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
{
|
{
|
||||||
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
||||||
|
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
|
||||||
|
// The sweep (z_shieldcoinbase / z_mergetoaddress) needs an unlocked wallet, so an encrypted
|
||||||
|
// wallet silently disabled the button. Offer an inline unlock instead of a dead end.
|
||||||
|
if (state_.isLocked()) {
|
||||||
|
static char s_mig_unlock_pass[128] = {0};
|
||||||
|
ImGui::TextWrapped("%s", TR("mig_unlock_prompt"));
|
||||||
|
ImGui::SetNextItemWidth(220 * dp);
|
||||||
|
const bool submit = ImGui::InputText("##mig_unlock", s_mig_unlock_pass, sizeof(s_mig_unlock_pass),
|
||||||
|
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if ((ui::material::TactileButton(TR("mig_unlock_btn"), ImVec2(120 * dp, 0)) || submit)
|
||||||
|
&& s_mig_unlock_pass[0]) {
|
||||||
|
unlockWallet(s_mig_unlock_pass, 600);
|
||||||
|
sodium_memzero(s_mig_unlock_pass, sizeof(s_mig_unlock_pass)); // don't retain the secret
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
if (!seed_migration_confirm_sweep_) {
|
||||||
if (!canSweep) ImGui::BeginDisabled();
|
if (!canSweep) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
||||||
beginSweepToSeedWallet();
|
seed_migration_confirm_sweep_ = true; // first click arms the confirm
|
||||||
if (!canSweep) ImGui::EndDisabled();
|
if (!canSweep) ImGui::EndDisabled();
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
refreshSeedMigrationBalance();
|
refreshSeedMigrationBalance();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
||||||
close(); // pending state persists; resumes here next time
|
close(); // pending state persists; resumes here next time
|
||||||
|
} else {
|
||||||
|
// Armed: state the amount being moved before the single sweep transaction fires.
|
||||||
|
char cbuf[160]; snprintf(cbuf, sizeof(cbuf), TR("mig_sweep_confirm"), seed_migration_balance_);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", cbuf);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (!canSweep) ImGui::BeginDisabled();
|
||||||
|
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(170 * dp, 0))) {
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
beginSweepToSeedWallet(); // unchanged
|
||||||
|
}
|
||||||
|
if (!canSweep) ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5018,7 +5219,8 @@ void App::renderSeedMigrationDialog()
|
|||||||
|
|
||||||
case SeedMigrationStep::Confirming: {
|
case SeedMigrationStep::Confirming: {
|
||||||
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
||||||
const bool confirmed = seed_migration_sweep_confs_ >= 1;
|
const bool mined = seed_migration_sweep_confs_ >= 1; // remainder re-sweep stays offered once mined
|
||||||
|
const bool confirmed = dragonx::sweepConfsAreReorgSafe(seed_migration_sweep_confs_); // adopt needs reorg-safe depth
|
||||||
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
||||||
seed_migration_legacy_remaining_ <= dust;
|
seed_migration_legacy_remaining_ <= dust;
|
||||||
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
||||||
@@ -5038,25 +5240,47 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (confirmed && legacyEmpty) {
|
if (confirmed && legacyEmpty) {
|
||||||
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
|
||||||
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
} else if (mined && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
||||||
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
||||||
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
||||||
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
||||||
ImGui::TextWrapped("%s", TR("mig_remainder"));
|
ImGui::TextWrapped("%s", TR("mig_remainder"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
|
if (!seed_migration_confirm_sweep_) {
|
||||||
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
|
||||||
beginSweepToSeedWallet();
|
seed_migration_confirm_sweep_ = true; // first click arms
|
||||||
|
} else {
|
||||||
|
char rcbuf[160];
|
||||||
|
snprintf(rcbuf, sizeof(rcbuf), TR("mig_sweep_confirm"), seed_migration_legacy_remaining_);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", rcbuf);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(180 * dp, 0))) {
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
beginSweepToSeedWallet(); // unchanged
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
}
|
||||||
|
} else if (mined && legacyEmpty) {
|
||||||
|
// Mined but not yet reorg-safe deep: say so instead of the misleading "waiting to be mined".
|
||||||
|
char dbuf[200]; snprintf(dbuf, sizeof(dbuf), TR("mig_waiting_depth"), dragonx::kSweepAdoptMinConfs);
|
||||||
|
ImGui::TextWrapped("%s", dbuf);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
||||||
}
|
}
|
||||||
|
// Refresh / Later are hidden while the sweep confirm is armed, to keep that row uncluttered.
|
||||||
|
if (!seed_migration_confirm_sweep_) {
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
pollSweepStatus(/*forceBalance=*/true);
|
pollSweepStatus(/*forceBalance=*/true);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
||||||
close(); // pending state + txid persist; resumes here next time
|
close(); // pending state + txid persist; resumes here next time
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5973,16 +6197,16 @@ bool App::startEmbeddedDaemon()
|
|||||||
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case daemon::EmbeddedDaemon::State::Starting:
|
case daemon::EmbeddedDaemon::State::Starting:
|
||||||
setDaemonStatus(TR("sb_starting_daemon"));
|
setDaemonStatus(util::trCopy("sb_starting_daemon"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Running:
|
case daemon::EmbeddedDaemon::State::Running:
|
||||||
setDaemonStatus(TR("sb_dragonxd_running"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_running"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopping:
|
case daemon::EmbeddedDaemon::State::Stopping:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopped:
|
case daemon::EmbeddedDaemon::State::Stopped:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Error:
|
case daemon::EmbeddedDaemon::State::Error:
|
||||||
setDaemonStatus("Error: " + msg);
|
setDaemonStatus("Error: " + msg);
|
||||||
@@ -6308,6 +6532,7 @@ void App::rescanBlockchain()
|
|||||||
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
|
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
|
||||||
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
|
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
|
||||||
state_.sync.rescanning = true;
|
state_.sync.rescanning = true;
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA
|
||||||
rescan_confirmed_active_ = false;
|
rescan_confirmed_active_ = false;
|
||||||
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
|
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
|
||||||
state_.sync.rescan_progress = 0.0f;
|
state_.sync.rescan_progress = 0.0f;
|
||||||
@@ -8107,6 +8332,7 @@ std::string App::buildDiagnosticsReport()
|
|||||||
|
|
||||||
void App::restartDaemon()
|
void App::restartDaemon()
|
||||||
{
|
{
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
|
||||||
if (!supportsFullNodeLifecycleActions()) {
|
if (!supportsFullNodeLifecycleActions()) {
|
||||||
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
||||||
return;
|
return;
|
||||||
@@ -8117,6 +8343,7 @@ void App::restartDaemon()
|
|||||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
||||||
if (!decision.allowed) return;
|
if (!decision.allowed) return;
|
||||||
if (refuseIfNodeBusy()) return;
|
if (refuseIfNodeBusy()) return;
|
||||||
|
daemon_restart_pending_.store(false); // a restart is actually happening — clear the reminder
|
||||||
daemon_restarting_ = true;
|
daemon_restarting_ = true;
|
||||||
|
|
||||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
#include "resources/provision_state.h"
|
#include "resources/provision_state.h"
|
||||||
#include "util/background_sampler.h"
|
#include "util/background_sampler.h"
|
||||||
#include "util/coalescing_writer.h"
|
#include "util/coalescing_writer.h"
|
||||||
|
#include "util/sync_eta.h"
|
||||||
#include "util/fetch_backoff.h"
|
#include "util/fetch_backoff.h"
|
||||||
#include "util/idle_tracker.h"
|
#include "util/idle_tracker.h"
|
||||||
#include "util/pool_stats_service.h"
|
#include "util/pool_stats_service.h"
|
||||||
@@ -113,6 +114,7 @@ class App {
|
|||||||
public:
|
public:
|
||||||
App();
|
App();
|
||||||
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
||||||
|
void wipeDisplayedSecrets(); // scrub + close any on-screen secret (seed/key displays) on wallet lock
|
||||||
~App();
|
~App();
|
||||||
|
|
||||||
// Non-copyable
|
// Non-copyable
|
||||||
@@ -331,6 +333,10 @@ public:
|
|||||||
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
||||||
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
||||||
bool lockLiteWallet();
|
bool lockLiteWallet();
|
||||||
|
// Unlock the lite wallet with the same escalating attempt-lockout as the full node (persisted across restarts)
|
||||||
|
// and set state_.locked=false right away on success (the poll lags). Takes the passphrase by value and wipes
|
||||||
|
// it. On failure *err (if given) holds a user-facing reason ("Unlock failed..." or the lockout wait).
|
||||||
|
bool unlockLiteWallet(std::string passphrase, std::string* err = nullptr);
|
||||||
// (Re)build the lite controller from current settings so a changed lite-server selection
|
// (Re)build the lite controller from current settings so a changed lite-server selection
|
||||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||||
void rebuildLiteWallet(bool force = false);
|
void rebuildLiteWallet(bool force = false);
|
||||||
@@ -389,6 +395,11 @@ public:
|
|||||||
void startMining(int threads);
|
void startMining(int threads);
|
||||||
void stopMining();
|
void stopMining();
|
||||||
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
|
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
|
||||||
|
// Persistent mining error (last start/stop/pool failure) so a failed miner isn't just a fading toast.
|
||||||
|
const std::string& miningLastError() const { return mining_last_error_; }
|
||||||
|
bool miningErrorIsAntivirus() const { return mining_error_av_; }
|
||||||
|
void clearMiningError() { mining_last_error_.clear(); mining_error_av_ = false; }
|
||||||
|
void showAntivirusHelp() { pending_antivirus_dialog_ = true; } // Windows-only dialog; inert elsewhere
|
||||||
|
|
||||||
// Pool mining (xmrig)
|
// Pool mining (xmrig)
|
||||||
void startPoolMining(int threads);
|
void startPoolMining(int threads);
|
||||||
@@ -442,6 +453,9 @@ public:
|
|||||||
// Address operations
|
// Address operations
|
||||||
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
|
// The daemon's actual reason the last new-address RPC failed (friendly-mapped), or "" on success. Lets
|
||||||
|
// the UI show e.g. "HD seed not found" (legacy/degraded wallet) instead of a generic "couldn't create".
|
||||||
|
const std::string& lastAddressError() const { return last_address_error_; }
|
||||||
|
|
||||||
// Hide/unhide addresses from the address list (persisted in settings)
|
// Hide/unhide addresses from the address list (persisted in settings)
|
||||||
void hideAddress(const std::string& addr);
|
void hideAddress(const std::string& addr);
|
||||||
@@ -551,10 +565,11 @@ public:
|
|||||||
private:
|
private:
|
||||||
App& app_;
|
App& app_;
|
||||||
};
|
};
|
||||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
|
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
|
||||||
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
|
// maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
|
||||||
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
|
// refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
|
||||||
// 30 s, as before), so a user's own send is never cut off mid-call.
|
// flag is cleared by the next tryConnect(). A user send aborted mid-flight is handled as a lost reply
|
||||||
|
// ("outcome unknown — check History"), never a double-send.
|
||||||
void abortMaintenanceCalls();
|
void abortMaintenanceCalls();
|
||||||
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
||||||
// full node that is past warmup, not rescanning and not busy.
|
// full node that is past warmup, not rescanning and not busy.
|
||||||
@@ -635,6 +650,9 @@ public:
|
|||||||
// Dialog triggers (used by settings page to open modal dialogs)
|
// Dialog triggers (used by settings page to open modal dialogs)
|
||||||
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
||||||
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
||||||
|
void showImportWalletFileDialog() { show_import_wallet_file_ = true; } // import a whole legacy key dump
|
||||||
|
// Import every key from an old wallet's z_exportwallet/dumpwallet dump file (z_importwallet, one rescan).
|
||||||
|
void importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback);
|
||||||
void showExportKeyDialog() { show_export_key_ = true; }
|
void showExportKeyDialog() { show_export_key_ = true; }
|
||||||
void showBackupDialog() { show_backup_ = true; }
|
void showBackupDialog() { show_backup_ = true; }
|
||||||
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
||||||
@@ -763,6 +781,9 @@ public:
|
|||||||
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
||||||
*/
|
*/
|
||||||
void restartDaemon();
|
void restartDaemon();
|
||||||
|
// A newer daemon binary is installed but the running node is still the old one until a restart.
|
||||||
|
void markDaemonRestartPending() { daemon_restart_pending_.store(true); }
|
||||||
|
bool daemonRestartPending() const { return daemon_restart_pending_.load(); }
|
||||||
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
|
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
|
||||||
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
|
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
|
||||||
// via the identity-scoped caches (P1). No-op if that wallet is already active.
|
// via the identity-scoped caches (P1). No-op if that wallet is already active.
|
||||||
@@ -921,6 +942,8 @@ private:
|
|||||||
// curve on every failed path (a PIN RPC failure used to skip it).
|
// curve on every failed path (a PIN RPC failure used to skip it).
|
||||||
void applyUnlockSuccess(const std::string& passphrase, int timeout);
|
void applyUnlockSuccess(const std::string& passphrase, int timeout);
|
||||||
void applyUnlockFailure(const std::string& errorMessage);
|
void applyUnlockFailure(const std::string& errorMessage);
|
||||||
|
// Restore the persisted unlock-failure count + lockout (once per lock-screen session, per wallet).
|
||||||
|
void restoreLockoutState();
|
||||||
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
|
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
|
||||||
void applyUnlockNotCounted(const std::string& errorMessage);
|
void applyUnlockNotCounted(const std::string& errorMessage);
|
||||||
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
|
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
|
||||||
@@ -1176,6 +1199,7 @@ private:
|
|||||||
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
||||||
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
|
void verifyThenAdoptSeedWallet(); // re-read sweep confs + minconf-0 balance, then beginAdoptSeedWallet() iff safe
|
||||||
void provisionChatIdentityFromSecret(std::string secret);
|
void provisionChatIdentityFromSecret(std::string secret);
|
||||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||||
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
||||||
@@ -1303,6 +1327,7 @@ private:
|
|||||||
|
|
||||||
// Daemon restart (e.g. after changing debug log categories)
|
// Daemon restart (e.g. after changing debug log categories)
|
||||||
std::atomic<bool> daemon_restarting_{false};
|
std::atomic<bool> daemon_restarting_{false};
|
||||||
|
std::atomic<bool> daemon_restart_pending_{false}; // updated binary installed, awaiting a restart
|
||||||
|
|
||||||
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
|
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
|
||||||
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
|
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
|
||||||
@@ -1331,6 +1356,7 @@ private:
|
|||||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||||
|
std::string last_address_error_; // daemon reason the last z_getnewaddress/getnewaddress failed ("" = ok)
|
||||||
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||||
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||||
@@ -1393,10 +1419,15 @@ private:
|
|||||||
// UI State
|
// UI State
|
||||||
bool quit_requested_ = false;
|
bool quit_requested_ = false;
|
||||||
bool show_demo_window_ = false;
|
bool show_demo_window_ = false;
|
||||||
bool show_settings_ = false;
|
|
||||||
bool show_about_ = false;
|
bool show_about_ = false;
|
||||||
bool show_faq_ = false;
|
bool show_faq_ = false;
|
||||||
bool show_import_key_ = false;
|
bool show_import_key_ = false;
|
||||||
|
bool show_import_wallet_file_ = false; // the "import keys from a backup file" modal is open
|
||||||
|
char import_wallet_file_path_[1024] = {0}; // typed path to the old wallet's key dump (not a secret)
|
||||||
|
bool import_wallet_file_busy_ = false; // a z_importwallet long call is in flight
|
||||||
|
bool import_wallet_file_done_ = false; // terminal result reached (ok/err in import_wallet_file_ok_)
|
||||||
|
bool import_wallet_file_ok_ = false;
|
||||||
|
std::string import_wallet_file_status_; // result / error text shown in the modal
|
||||||
bool show_export_key_ = false;
|
bool show_export_key_ = false;
|
||||||
bool show_backup_ = false;
|
bool show_backup_ = false;
|
||||||
bool show_seed_backup_ = false;
|
bool show_seed_backup_ = false;
|
||||||
@@ -1447,6 +1478,9 @@ private:
|
|||||||
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
|
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
|
||||||
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
|
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
|
||||||
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
|
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
|
||||||
|
bool seed_migration_understood_ = false; // 2nd ack: funds recoverable only via the phrase
|
||||||
|
bool seed_migration_confirm_discard_ = false; // inline "are you sure" gate for Discard (never persisted)
|
||||||
|
bool seed_migration_confirm_sweep_ = false; // inline "are you sure" gate for Sweep (never persisted)
|
||||||
std::string seed_migration_status_; // main-thread display (progress / error text)
|
std::string seed_migration_status_; // main-thread display (progress / error text)
|
||||||
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
|
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
|
||||||
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
|
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
|
||||||
@@ -1559,6 +1593,7 @@ private:
|
|||||||
|
|
||||||
// Connection
|
// Connection
|
||||||
std::string connection_status_ = "Disconnected";
|
std::string connection_status_ = "Disconnected";
|
||||||
|
bool rpc_auth_failed_ = false; // last connect hit an auth failure (bad rpcuser/rpcpassword); drives the banner
|
||||||
bool connection_in_progress_ = false;
|
bool connection_in_progress_ = false;
|
||||||
bool remote_rpc_plaintext_warning_shown_ = false;
|
bool remote_rpc_plaintext_warning_shown_ = false;
|
||||||
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
|
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
|
||||||
@@ -1744,6 +1779,14 @@ private:
|
|||||||
|
|
||||||
// Mining toggle guard (prevents concurrent setgenerate calls)
|
// Mining toggle guard (prevents concurrent setgenerate calls)
|
||||||
std::atomic<bool> mining_toggle_in_progress_{false};
|
std::atomic<bool> mining_toggle_in_progress_{false};
|
||||||
|
// Mining error state (main-thread only — every writer is in a MainCb or render).
|
||||||
|
std::string mining_last_error_;
|
||||||
|
// Sync-ETA estimator (UI thread only). onConnected/onDisconnected may run off-thread, so they set
|
||||||
|
// the atomic flag and the status bar consumes it before update().
|
||||||
|
util::SyncRateEstimator sync_eta_;
|
||||||
|
std::atomic<bool> sync_eta_reset_pending_{false};
|
||||||
|
bool mining_error_av_ = false;
|
||||||
|
void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; }
|
||||||
|
|
||||||
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
|
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
|
||||||
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
|
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
|
||||||
@@ -1912,6 +1955,7 @@ private:
|
|||||||
// First-run wizard state
|
// First-run wizard state
|
||||||
WizardPhase wizard_phase_ = WizardPhase::None;
|
WizardPhase wizard_phase_ = WizardPhase::None;
|
||||||
std::unique_ptr<util::Bootstrap> bootstrap_;
|
std::unique_ptr<util::Bootstrap> bootstrap_;
|
||||||
|
std::string wizard_bootstrap_url_; // source the wizard last started ("" = default); Retry keeps it
|
||||||
bool bootstrap_downloading_ = false; // true while settings bootstrap dialog is active
|
bool bootstrap_downloading_ = false; // true while settings bootstrap dialog is active
|
||||||
std::string wizard_pending_passphrase_; // held until daemon connects
|
std::string wizard_pending_passphrase_; // held until daemon connects
|
||||||
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
||||||
@@ -1957,6 +2001,7 @@ private:
|
|||||||
int lock_attempts_ = 0;
|
int lock_attempts_ = 0;
|
||||||
float lock_lockout_timer_ = 0.0f;
|
float lock_lockout_timer_ = 0.0f;
|
||||||
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
|
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
|
||||||
|
bool lock_state_restored_ = false; // one-shot: persisted lockout restored for this lock-screen session
|
||||||
bool lock_unlock_in_progress_ = false;
|
bool lock_unlock_in_progress_ = false;
|
||||||
|
|
||||||
// Encrypt wallet dialog state
|
// Encrypt wallet dialog state
|
||||||
@@ -2024,6 +2069,7 @@ private:
|
|||||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
|
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
|
||||||
void renderExportKeyDialog();
|
void renderExportKeyDialog();
|
||||||
void renderBackupDialog();
|
void renderBackupDialog();
|
||||||
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
||||||
|
|||||||
+368
-52
@@ -41,6 +41,10 @@
|
|||||||
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <fcntl.h> // open/fsync: flush the staged wallet + .bak before the atomic swap
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||||
@@ -406,6 +410,7 @@ void App::tryConnect()
|
|||||||
++connect_attempt;
|
++connect_attempt;
|
||||||
|
|
||||||
connection_in_progress_ = true;
|
connection_in_progress_ = true;
|
||||||
|
rpc_auth_failed_ = false; // re-arm: a fresh connect attempt clears any prior auth failure
|
||||||
connection_status_ = TR("sb_loading_config");
|
connection_status_ = TR("sb_loading_config");
|
||||||
|
|
||||||
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
||||||
@@ -558,7 +563,12 @@ void App::tryConnect()
|
|||||||
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
||||||
}
|
}
|
||||||
|
|
||||||
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
// A previous teardown leaves rpc_'s abort flag set (open() no longer clears it, so an abort issued
|
||||||
|
// while this connect is still queued is not erased). This is the reconnect entry point: clear it here
|
||||||
|
// on the main thread, after the daemon_restarting_/shutting_down_ gates above.
|
||||||
|
if (rpc_ && !shutting_down_.load()) rpc_->clearAbort();
|
||||||
|
const std::uint64_t connectSession = rpc_session_; // main thread; a teardown/onConnected bumps it
|
||||||
|
worker_->post([this, config, daemonStarting, externalDetected, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
||||||
std::string connectErr = rpc_->getLastConnectError();
|
std::string connectErr = rpc_->getLastConnectError();
|
||||||
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
||||||
@@ -566,7 +576,18 @@ void App::tryConnect()
|
|||||||
std::string warmupStatus = rpc_->getWarmupStatus();
|
std::string warmupStatus = rpc_->getWarmupStatus();
|
||||||
|
|
||||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
||||||
connectKind]() {
|
connectKind, connectSession]() {
|
||||||
|
// A probe that succeeded after a wallet switch / adopt / restore / shutdown already tore the
|
||||||
|
// connection down must not run onConnected() against a daemon that is about to be stopped.
|
||||||
|
if (connected && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
VERBOSE_LOGF("[connect #%d] Dropping stale connect result (teardown raced the probe)\n", attempt);
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort(); // a worker call may hold curl_mutex_; don't block the UI on disconnect()
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
connection_in_progress_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
||||||
if (connected) connect_backoff_.reset();
|
if (connected) connect_backoff_.reset();
|
||||||
if (connected) {
|
if (connected) {
|
||||||
@@ -595,12 +616,19 @@ void App::tryConnect()
|
|||||||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
||||||
attempt, cookieConfig.hush_dir.c_str());
|
attempt, cookieConfig.hush_dir.c_str());
|
||||||
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, cookieConfig, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
||||||
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
||||||
cookieConfig.use_tls);
|
cookieConfig.use_tls);
|
||||||
return [this, cookieConfig, ok, attempt]() {
|
return [this, cookieConfig, ok, attempt, connectSession]() {
|
||||||
connection_in_progress_ = false;
|
connection_in_progress_ = false;
|
||||||
|
if (ok && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort();
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
return; // stale: a teardown raced the probe
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
||||||
saved_config_ = cookieConfig;
|
saved_config_ = cookieConfig;
|
||||||
@@ -608,6 +636,7 @@ void App::tryConnect()
|
|||||||
} else {
|
} else {
|
||||||
state_.connected = false;
|
state_.connected = false;
|
||||||
connection_status_ = TR("sb_auth_failed");
|
connection_status_ = TR("sb_auth_failed");
|
||||||
|
rpc_auth_failed_ = true;
|
||||||
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
|
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
|
||||||
ui::Notifications::instance().error(
|
ui::Notifications::instance().error(
|
||||||
"RPC authentication failed (HTTP 401). "
|
"RPC authentication failed (HTTP 401). "
|
||||||
@@ -621,6 +650,7 @@ void App::tryConnect()
|
|||||||
state_.connected = false;
|
state_.connected = false;
|
||||||
std::string confPath = rpc::Connection::getDefaultConfPath();
|
std::string confPath = rpc::Connection::getDefaultConfPath();
|
||||||
connection_status_ = TR("sb_auth_failed");
|
connection_status_ = TR("sb_auth_failed");
|
||||||
|
rpc_auth_failed_ = true;
|
||||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
|
||||||
"Edit the file or restart the daemon to regenerate credentials.\n",
|
"Edit the file or restart the daemon to regenerate credentials.\n",
|
||||||
attempt, confPath.c_str());
|
attempt, confPath.c_str());
|
||||||
@@ -738,6 +768,7 @@ void App::tryConnect()
|
|||||||
void App::onConnected()
|
void App::onConnected()
|
||||||
{
|
{
|
||||||
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
|
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // fresh connect → recompute sync ETA
|
||||||
++rpc_session_;
|
++rpc_session_;
|
||||||
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
||||||
state_.connected = true;
|
state_.connected = true;
|
||||||
@@ -828,6 +859,7 @@ void App::onConnected()
|
|||||||
fast_worker_->start();
|
fast_worker_->start();
|
||||||
}
|
}
|
||||||
// Connect on the fast worker's own thread (non-blocking to main)
|
// Connect on the fast worker's own thread (non-blocking to main)
|
||||||
|
if (fast_rpc_ && !shutting_down_.load()) fast_rpc_->clearAbort(); // open() no longer clears a prior teardown's abort
|
||||||
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
||||||
saved_config_.rpcuser, saved_config_.rpcpassword,
|
saved_config_.rpcuser, saved_config_.rpcpassword,
|
||||||
@@ -851,6 +883,7 @@ void App::onConnected()
|
|||||||
|
|
||||||
void App::onDisconnected(const std::string& reason)
|
void App::onDisconnected(const std::string& reason)
|
||||||
{
|
{
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
|
||||||
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
|
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
|
||||||
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
|
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
|
||||||
// session reset below empty them, which would turn a later flush into a silent no-op.
|
// session reset below empty them, which would turn a later flush into a silent no-op.
|
||||||
@@ -886,8 +919,10 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
pending_opids_.clear();
|
pending_opids_.clear();
|
||||||
pending_send_info_.clear();
|
pending_send_info_.clear();
|
||||||
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
||||||
|
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
|
||||||
|
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
|
||||||
for (auto& entry : pending_send_callbacks_) {
|
for (auto& entry : pending_send_callbacks_) {
|
||||||
if (entry.second) entry.second(false, reason);
|
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
|
||||||
}
|
}
|
||||||
pending_send_callbacks_.clear();
|
pending_send_callbacks_.clear();
|
||||||
consecutive_core_failures_ = 0;
|
consecutive_core_failures_ = 0;
|
||||||
@@ -945,6 +980,13 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
||||||
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
||||||
fast_worker_.reset();
|
fast_worker_.reset();
|
||||||
|
// stop() discards undrained MainCbs, and these in-progress flags are cleared only inside
|
||||||
|
// those callbacks (unlock/lock/PIN/encrypt are posted on the fast lane when it is running).
|
||||||
|
// Release them here so the lock screen cannot show "Unlocking..." forever; the user can retry
|
||||||
|
// after reconnect, and the real encryption state is re-read on connect.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
pin_in_progress_ = false;
|
||||||
|
encrypt_in_progress_ = false;
|
||||||
}
|
}
|
||||||
if (fast_rpc_) {
|
if (fast_rpc_) {
|
||||||
fast_rpc_->disconnect();
|
fast_rpc_->disconnect();
|
||||||
@@ -1106,7 +1148,10 @@ void App::abortMaintenanceCalls()
|
|||||||
{
|
{
|
||||||
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
||||||
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
||||||
if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight
|
// Unconditional: an ordinary in-flight refresh/scan also holds curl_mutex_ for its whole
|
||||||
|
// curl_easy_perform, and every caller disconnect()s next. The abort is cleared by the next
|
||||||
|
// tryConnect() (rpc_) / onConnected() (fast_rpc_) before they reconnect.
|
||||||
|
if (rpc_) rpc_->requestAbort(); // cuts off whatever call is in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
bool App::walletScanReady() const
|
bool App::walletScanReady() const
|
||||||
@@ -1668,6 +1713,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
|
|||||||
pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault
|
pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault
|
||||||
lock_attempts_ = 0;
|
lock_attempts_ = 0;
|
||||||
lock_lockout_timer_ = 0.0f;
|
lock_lockout_timer_ = 0.0f;
|
||||||
|
lock_state_restored_ = false; // re-restore persisted lockout for the newly-active wallet
|
||||||
lock_error_msg_.clear();
|
lock_error_msg_.clear();
|
||||||
lock_error_timer_ = 0.0f;
|
lock_error_timer_ = 0.0f;
|
||||||
lock_unlock_in_progress_ = false;
|
lock_unlock_in_progress_ = false;
|
||||||
@@ -3526,6 +3572,7 @@ void App::startMining(int threads)
|
|||||||
const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
||||||
threads = std::clamp(threads, 1, maxThreads);
|
threads = std::clamp(threads, 1, maxThreads);
|
||||||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||||||
|
clearMiningError(); // a fresh attempt wipes any stale error
|
||||||
|
|
||||||
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
@@ -3545,7 +3592,9 @@ void App::startMining(int threads)
|
|||||||
DEBUG_LOGF("Mining started with %d threads\n", threads);
|
DEBUG_LOGF("Mining started with %d threads\n", threads);
|
||||||
} else {
|
} else {
|
||||||
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
|
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
|
||||||
ui::Notifications::instance().error(std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg));
|
const std::string merr = std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg);
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3556,6 +3605,7 @@ void App::stopMining()
|
|||||||
if (!supportsSoloMining()) return;
|
if (!supportsSoloMining()) return;
|
||||||
if (!state_.connected || !rpc_ || !worker_) return;
|
if (!state_.connected || !rpc_ || !worker_) return;
|
||||||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||||||
|
clearMiningError();
|
||||||
|
|
||||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
@@ -3577,7 +3627,9 @@ void App::stopMining()
|
|||||||
} else {
|
} else {
|
||||||
// Don't silently leave generate=true as if it worked: tell the user and let the next
|
// Don't silently leave generate=true as if it worked: tell the user and let the next
|
||||||
// getmininginfo refresh reconcile the true daemon state. (M-07)
|
// getmininginfo refresh reconcile the true daemon state. (M-07)
|
||||||
ui::Notifications::instance().error(std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg));
|
const std::string merr = std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg);
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3631,9 +3683,11 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
|
|||||||
// A pool pays with ordinary transactions, so only a shielded address can be paid (private chain).
|
// A pool pays with ordinary transactions, so only a shielded address can be paid (private chain).
|
||||||
if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
|
if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
|
||||||
!util::isValidRecipientAddress(cfg.worker_name)) {
|
!util::isValidRecipientAddress(cfg.worker_name)) {
|
||||||
ui::Notifications::instance().error(util::isTransparentAddress(cfg.worker_name)
|
const std::string merr = util::isTransparentAddress(cfg.worker_name)
|
||||||
? TR("mining_payout_needs_shielded")
|
? std::string(TR("mining_payout_needs_shielded"))
|
||||||
: "Pool payout address is not a valid DragonX address — mining not started.");
|
: std::string(TR("toast_pool_payout_invalid"));
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates
|
if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3664,6 +3718,7 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
|
|||||||
|
|
||||||
if (cfg.wallet_address.empty()) {
|
if (cfg.wallet_address.empty()) {
|
||||||
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
||||||
|
setMiningError(TR("toast_no_address_generate_z"));
|
||||||
ui::Notifications::instance().error(TR("toast_no_address_generate_z"));
|
ui::Notifications::instance().error(TR("toast_no_address_generate_z"));
|
||||||
if (retargetOnly) stopPoolMining();
|
if (retargetOnly) stopPoolMining();
|
||||||
return;
|
return;
|
||||||
@@ -3712,13 +3767,15 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
|
|||||||
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
||||||
// Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
// Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
||||||
if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
|
if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
|
||||||
ui::Notifications::instance().error(
|
setMiningError(TR("toast_av_blocked_xmrig"), /*av=*/true);
|
||||||
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
|
ui::Notifications::instance().error(TR("toast_av_blocked_xmrig"));
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
pending_antivirus_dialog_ = true;
|
pending_antivirus_dialog_ = true;
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
ui::Notifications::instance().error(std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err));
|
const std::string merr = std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err);
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3755,7 +3812,7 @@ void App::banPeer(const std::string& ip, int duration_seconds)
|
|||||||
}
|
}
|
||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err));
|
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err));
|
||||||
else refreshPeerInfo();
|
else { ui::Notifications::instance().success(TR("toast_peer_banned")); refreshPeerInfo(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3773,7 +3830,7 @@ void App::unbanPeer(const std::string& ip)
|
|||||||
}
|
}
|
||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err));
|
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err));
|
||||||
else refreshPeerInfo();
|
else { ui::Notifications::instance().success(TR("toast_peer_unbanned")); refreshPeerInfo(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3792,6 +3849,7 @@ void App::clearBans()
|
|||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
|
if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
|
||||||
state_.banned_peers.clear();
|
state_.banned_peers.clear();
|
||||||
|
ui::Notifications::instance().success(TR("toast_bans_cleared"));
|
||||||
refreshPeerInfo();
|
refreshPeerInfo();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3899,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
|
|||||||
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
||||||
: "Receive tab / New transparent address";
|
: "Receive tab / New transparent address";
|
||||||
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
||||||
std::string addr;
|
std::string addr, genErr;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
json result = rpc_->call(rpcMethod);
|
json result = rpc_->call(rpcMethod);
|
||||||
addr = result.get<std::string>();
|
addr = result.get<std::string>();
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
||||||
|
genErr = e.what(); // keep the real reason so the UI can show it instead of a generic toast
|
||||||
|
}
|
||||||
|
return [this, callback, addr, genErr, shielded, typeStr]() {
|
||||||
|
if (addr.empty()) {
|
||||||
|
// Surface the daemon's actual reason (friendly-mapped) rather than a generic "couldn't create".
|
||||||
|
last_address_error_ = genErr.empty() ? std::string() : util::friendlyRpcError(genErr);
|
||||||
|
// "HD seed not found" means a legacy / degraded wallet can't derive new HD keys. Flip degraded
|
||||||
|
// mode here so the UI explains it even for an EXTERNAL daemon — detectWalletDegraded() only
|
||||||
|
// reads the embedded daemon's startup log and would otherwise miss this.
|
||||||
|
if (shielded && genErr.find("HD seed not found") != std::string::npos) {
|
||||||
|
wallet_degraded_ = true;
|
||||||
|
if (!wallet_degraded_warned_) {
|
||||||
|
wallet_degraded_warned_ = true;
|
||||||
|
ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last_address_error_.clear();
|
||||||
}
|
}
|
||||||
return [this, callback, addr, shielded, typeStr]() {
|
|
||||||
if (!addr.empty()) {
|
if (!addr.empty()) {
|
||||||
// Inject immediately so UI can select the address next frame
|
// Inject immediately so UI can select the address next frame
|
||||||
AddressInfo info;
|
AddressInfo info;
|
||||||
@@ -5381,6 +5456,7 @@ bool App::lockLiteWallet()
|
|||||||
if (!lite_wallet_) return false;
|
if (!lite_wallet_) return false;
|
||||||
const bool ok = lite_wallet_->lockWallet();
|
const bool ok = lite_wallet_->lockWallet();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
wipeDisplayedSecrets(); // scrub any revealed seed/key display on lock (matches full-node)
|
||||||
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
||||||
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
||||||
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
||||||
@@ -5395,6 +5471,69 @@ bool App::lockLiteWallet()
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool App::unlockLiteWallet(std::string passphrase, std::string* err)
|
||||||
|
{
|
||||||
|
const auto fail = [&](const std::string& m) { if (err) *err = m; };
|
||||||
|
const auto waitMsg = [](long long secs) {
|
||||||
|
char msg[128];
|
||||||
|
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), (double)secs);
|
||||||
|
return std::string(msg);
|
||||||
|
};
|
||||||
|
if (!lite_wallet_ || !lite_wallet_->walletOpen() || passphrase.empty()) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false; // nothing to attempt: not a wrong-credential failure, not counted
|
||||||
|
}
|
||||||
|
// Restore the persisted attempt count once per session (the full-node lock screen does the same).
|
||||||
|
if (!lock_state_restored_) {
|
||||||
|
lock_state_restored_ = true;
|
||||||
|
restoreLockoutState();
|
||||||
|
}
|
||||||
|
// Enforce the lockout from the persisted wall-clock expiry: lite has no per-frame lockout timer.
|
||||||
|
if (settings_ && settings_->getLockStateWallet() == settings_->getActiveWalletFile()) {
|
||||||
|
const long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
|
||||||
|
if (remaining > 0) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(waitMsg(remaining));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock_lockout_timer_ = 0.0f; // expired: don't let a stale in-memory value re-arm the curve
|
||||||
|
|
||||||
|
const bool ok = lite_wallet_->unlockWallet(std::move(passphrase)); // wipes the passphrase
|
||||||
|
if (!ok) {
|
||||||
|
const auto st = lite_wallet_->encryptionStatus();
|
||||||
|
if (st.ok && !(st.encrypted && st.locked)) {
|
||||||
|
// Not a wrong credential: already unlocked (e.g. a racing prompt) or not encrypted. Don't count it.
|
||||||
|
state_.encrypted = st.encrypted;
|
||||||
|
state_.locked = false;
|
||||||
|
if (st.encrypted) { idle_.noteActivity(); return true; }
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
applyUnlockFailure(TR("lite_unlock_failed")); // bump counter, arm lockout curve, persist
|
||||||
|
fail(lock_lockout_timer_ > 0.0f ? waitMsg((long long)std::ceil(lock_lockout_timer_))
|
||||||
|
: std::string(TR("lite_unlock_failed")));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: clear the attempt state (and its persisted copy) and reflect the unlock NOW — the next poll lags.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
lock_error_msg_.clear();
|
||||||
|
lock_attempts_ = 0;
|
||||||
|
lock_lockout_timer_ = 0.0f;
|
||||||
|
if (settings_ && settings_->getLockFailedAttempts() != 0) {
|
||||||
|
settings_->setLockFailedAttempts(0);
|
||||||
|
settings_->setLockoutUntilEpoch(0);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
|
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||||
|
idle_.noteActivity();
|
||||||
|
state_.encrypted = true;
|
||||||
|
state_.locked = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool App::seedChatDemoData()
|
bool App::seedChatDemoData()
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
||||||
@@ -5607,6 +5746,48 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import a whole wallet-key dump file (the output of the OLD wallet's z_exportwallet / dumpwallet, or
|
||||||
|
// SilentDragon's "export all private keys") via z_importwallet: the daemon reads every key from the
|
||||||
|
// file and does ONE rescan — the right tool for recovering a legacy wallet.dat the new client can't open,
|
||||||
|
// and far faster than importing keys one by one. Only the PATH touches our memory, never the keys. Runs on
|
||||||
|
// the long-call lane (no client timeout: the rescan can take a long time). Full-node only.
|
||||||
|
void App::importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback)
|
||||||
|
{
|
||||||
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
|
if (callback) callback(false, TR("import_key_need_node"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string p(path);
|
||||||
|
while (!p.empty() && (p.front()==' '||p.front()=='\t'||p.front()=='\n'||p.front()=='\r')) p.erase(p.begin());
|
||||||
|
while (!p.empty() && (p.back()==' '||p.back()=='\t'||p.back()=='\n'||p.back()=='\r')) p.pop_back();
|
||||||
|
if (p.empty()) { if (callback) callback(false, TR("import_wallet_file_need_path")); return; }
|
||||||
|
{
|
||||||
|
std::string why; const char* trKey = nullptr;
|
||||||
|
if (longWalletOpRefused(&why, &trKey)) { if (callback) callback(false, TR(trKey)); return; }
|
||||||
|
}
|
||||||
|
const bool started = runLongWalletTask([this, p, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||||
|
std::string err;
|
||||||
|
try {
|
||||||
|
rpc::RPCClient::TraceScope trace("Settings / Import wallet file");
|
||||||
|
c.call("z_importwallet", {p}, 0L); // reads the dump + one rescan; 0 = no client timeout
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
err = e.what();
|
||||||
|
if (!err.empty() && err.find("Cannot open") != std::string::npos)
|
||||||
|
err = TR("import_wallet_file_not_found"); // the most common cause: wrong/missing path
|
||||||
|
} catch (...) {
|
||||||
|
err = "Import failed (unknown error)";
|
||||||
|
}
|
||||||
|
return [this, err, callback]() {
|
||||||
|
if (!err.empty()) { if (callback) callback(false, err); return; }
|
||||||
|
invalidateAddressValidationCache();
|
||||||
|
refreshAddresses();
|
||||||
|
markWalletMutated(/*forceBalance=*/true); // keys added: balance changes with no tip/mempool signal
|
||||||
|
if (callback) callback(true, "");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!started && callback) callback(false, TR("appx_wallet_op_running"));
|
||||||
|
}
|
||||||
|
|
||||||
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||||||
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
||||||
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
||||||
@@ -5617,7 +5798,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
const std::string& destExisting)
|
const std::string& destExisting)
|
||||||
{
|
{
|
||||||
if (!state_.connected || !rpc_ || !worker_) {
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
sweep_status_ = "Not connected to the daemon.";
|
sweep_status_ = TR("err_not_connected_daemon");
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5629,7 +5810,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
||||||
// can't sign, and this must never accidentally route to z_importviewingkey.
|
// can't sign, and this must never accidentally route to z_importviewingkey.
|
||||||
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
||||||
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
|
sweep_status_ = TR("sweep_err_need_spending_key");
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5647,7 +5828,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
sweep_step_ = SweepStep::Running;
|
sweep_step_ = SweepStep::Running;
|
||||||
sweep_status_ = "Importing key & rescanning…";
|
sweep_status_ = TR("sweep_status_importing");
|
||||||
sweep_txid_.clear();
|
sweep_txid_.clear();
|
||||||
sweep_dest_shown_.clear();
|
sweep_dest_shown_.clear();
|
||||||
|
|
||||||
@@ -5708,12 +5889,12 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
||||||
|
|
||||||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||||||
|
// Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as
|
||||||
|
// 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup.
|
||||||
auto balAt = [&](int minconf) -> double {
|
auto balAt = [&](int minconf) -> double {
|
||||||
try {
|
|
||||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||||
return b.is_number() ? b.get<double>()
|
return b.is_number() ? b.get<double>()
|
||||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||||
} catch (...) { return 0.0; }
|
|
||||||
};
|
};
|
||||||
double bal = balAt(1);
|
double bal = balAt(1);
|
||||||
if (bal <= 0.0) {
|
if (bal <= 0.0) {
|
||||||
@@ -5741,7 +5922,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
err = e.what();
|
err = e.what();
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
err = "Sweep failed (unknown error)";
|
err = TR("sweep_err_unknown");
|
||||||
}
|
}
|
||||||
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
|
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
|
||||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||||
@@ -5749,16 +5930,16 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
invalidateAddressValidationCache();
|
invalidateAddressValidationCache();
|
||||||
refreshAddresses();
|
refreshAddresses();
|
||||||
if (!err.empty()) {
|
if (!err.empty()) {
|
||||||
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
|
if (err == "__NOFUNDS__") sweep_status_ = TR("sweep_err_nofunds");
|
||||||
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
|
else if (err == "__UNCONFIRMED__") sweep_status_ = TR("sweep_err_unconfirmed");
|
||||||
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
|
else if (err == "__ALREADY__") sweep_status_ = TR("sweep_err_already");
|
||||||
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
|
else if (err == "__DUST__") sweep_status_ = TR("sweep_err_dust");
|
||||||
else sweep_status_ = err;
|
else sweep_status_ = err;
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sweep_dest_shown_ = dest;
|
sweep_dest_shown_ = dest;
|
||||||
sweep_status_ = "Sweeping funds to your address…";
|
sweep_status_ = TR("sweep_status_sweeping");
|
||||||
// Send all funds from the swept address to the destination through the tested z_sendmany
|
// Send all funds from the swept address to the destination through the tested z_sendmany
|
||||||
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
||||||
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
||||||
@@ -6265,7 +6446,7 @@ void App::showSeedMigrationDialog()
|
|||||||
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
||||||
trackOperation(sweepOpid);
|
trackOperation(sweepOpid);
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Checking on the previous sweep…";
|
seed_migration_status_ = TR("migrate_status_checking_sweep");
|
||||||
break;
|
break;
|
||||||
case MigrationResume::SweepGate:
|
case MigrationResume::SweepGate:
|
||||||
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
||||||
@@ -6343,15 +6524,15 @@ void App::beginSweepToSeedWallet()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rpc_ || !worker_ || !state_.connected) {
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
seed_migration_status_ = "Not connected to the daemon.";
|
seed_migration_status_ = TR("err_not_connected_daemon");
|
||||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||||
}
|
}
|
||||||
if (seed_migration_dest_.empty()) {
|
if (seed_migration_dest_.empty()) {
|
||||||
seed_migration_status_ = "No destination address for the sweep.";
|
seed_migration_status_ = TR("migrate_err_no_dest");
|
||||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||||
}
|
}
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Building the sweep transaction…";
|
seed_migration_status_ = TR("migrate_status_building");
|
||||||
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
||||||
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
||||||
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
||||||
@@ -6367,7 +6548,27 @@ void App::beginSweepToSeedWallet()
|
|||||||
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
||||||
opid = res.value("opid", std::string());
|
opid = res.value("opid", std::string());
|
||||||
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
||||||
} catch (const std::exception& e) { err = e.what(); }
|
} catch (const std::exception& e) {
|
||||||
|
// z_mergetoaddress deliberately ignores coinbase UTXOs, so a miner whose legacy balance is all
|
||||||
|
// mined coinbase gets "Could not find any funds to merge". Fall back to z_shieldcoinbase, which
|
||||||
|
// DOES move coinbase, to the same shielded destination. It returns an opid exactly like the merge,
|
||||||
|
// so the adopt gate (one opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged; a batch
|
||||||
|
// limit or a mixed coinbase+other wallet is drained by the existing "Sweep remaining" re-sweep.
|
||||||
|
const std::string mergeErr = e.what();
|
||||||
|
if (mergeErr.find("find any funds to merge") != std::string::npos) {
|
||||||
|
try {
|
||||||
|
// Fixed-decimal fee string (ParseFixedPoint rejects a double that serializes as "5e-05").
|
||||||
|
const std::string feeStr = util::formatAmountFixed(DRAGONX_DEFAULT_FEE);
|
||||||
|
auto sres = rpc_->call("z_shieldcoinbase", {std::string("*"), dest, feeStr, 50});
|
||||||
|
opid = sres.value("opid", std::string());
|
||||||
|
if (opid.empty()) err = "The daemon returned no operation id for the coinbase shield.";
|
||||||
|
} catch (const std::exception& e2) {
|
||||||
|
err = e2.what(); // e.g. "Could not find any coinbase funds to shield" = genuinely empty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = mergeErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
return [this, opid, err]() {
|
return [this, opid, err]() {
|
||||||
if (!err.empty() || opid.empty()) {
|
if (!err.empty() || opid.empty()) {
|
||||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||||
@@ -6391,7 +6592,7 @@ void App::beginSweepToSeedWallet()
|
|||||||
}
|
}
|
||||||
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
||||||
trackOperation(opid);
|
trackOperation(opid);
|
||||||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
seed_migration_status_ = TR("migrate_status_waiting_accept");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6543,6 +6744,57 @@ void App::locateSweepTxid()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final gate before the swap: on the RPC worker, RE-READ the sweep tx's confirmations (when a sweep txid
|
||||||
|
// exists) and z_gettotalbalance with minconf 0 (includes unconfirmed, unlike the default minconf 1), then
|
||||||
|
// - only if both are reorg-safe, the node is not syncing and the legacy balance is ~0 - call
|
||||||
|
// beginAdoptSeedWallet() on the main thread, so the disconnect + daemon stop follow these reads at once.
|
||||||
|
// Read-only RPCs; any failure blocks adopt (nothing is stopped or swapped) and the user may retry.
|
||||||
|
void App::verifyThenAdoptSeedWallet()
|
||||||
|
{
|
||||||
|
if (capture_mode_) { beginAdoptSeedWallet(); return; } // UI sweep: unchanged behaviour
|
||||||
|
if (seed_migration_confirm_in_flight_) return;
|
||||||
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seed_migration_confirm_in_flight_ = true;
|
||||||
|
const std::string txid = seed_migration_sweep_txid_;
|
||||||
|
const bool needTx = !txid.empty();
|
||||||
|
const bool syncing = effectivelySyncing(); // main-thread snapshot for the worker
|
||||||
|
worker_->post([this, txid, needTx, syncing]() -> rpc::RPCWorker::MainCb {
|
||||||
|
int confs = 0; double total = -1.0; bool ok = false;
|
||||||
|
rpc::RPCClient::TraceScope trace("Migrate / pre-adopt verify");
|
||||||
|
try {
|
||||||
|
if (needTx) {
|
||||||
|
auto tx = rpc_->call("gettransaction", {txid});
|
||||||
|
if (!tx.contains("confirmations") || !tx["confirmations"].is_number()) throw std::runtime_error("no confirmations");
|
||||||
|
confs = tx["confirmations"].get<int>();
|
||||||
|
}
|
||||||
|
auto bal = rpc_->call("z_gettotalbalance", nlohmann::json::array({0})); // minconf 0
|
||||||
|
if (bal.contains("total") && bal["total"].is_string()) {
|
||||||
|
total = std::stod(bal["total"].get<std::string>());
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (...) { ok = false; }
|
||||||
|
return [this, needTx, syncing, confs, total, ok]() {
|
||||||
|
seed_migration_confirm_in_flight_ = false;
|
||||||
|
if (ok) {
|
||||||
|
if (needTx) seed_migration_sweep_confs_ = confs;
|
||||||
|
seed_migration_legacy_remaining_ = total;
|
||||||
|
}
|
||||||
|
// Only act while still on a step that offers Adopt (the user may have closed/changed it).
|
||||||
|
const bool onAdoptStep = seed_migration_step_ == SeedMigrationStep::Confirming ||
|
||||||
|
seed_migration_step_ == SeedMigrationStep::Sweep;
|
||||||
|
if (ok && onAdoptStep &&
|
||||||
|
dragonx::adoptReverifyPasses(needTx, confs, total, syncing, DRAGONX_DEFAULT_FEE)) {
|
||||||
|
beginAdoptSeedWallet(); // unchanged: stop daemon -> swap -> restart with -rescan
|
||||||
|
} else if (onAdoptStep) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"), 12.0f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||||||
@@ -6550,7 +6802,7 @@ void App::beginAdoptSeedWallet()
|
|||||||
{
|
{
|
||||||
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
||||||
seed_migration_step_ = SeedMigrationStep::Adopting;
|
seed_migration_step_ = SeedMigrationStep::Adopting;
|
||||||
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…";
|
seed_migration_status_ = TR("migrate_status_restarting");
|
||||||
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
||||||
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
||||||
daemon_restarting_ = true;
|
daemon_restarting_ = true;
|
||||||
@@ -6604,22 +6856,54 @@ void App::beginAdoptSeedWallet()
|
|||||||
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
||||||
if (!fs::exists(newWallet)) {
|
if (!fs::exists(newWallet)) {
|
||||||
err = "The new wallet file is missing; your wallet was not changed.";
|
err = "The new wallet file is missing; your wallet was not changed.";
|
||||||
|
} else if (!util::probeWalletFile(newWallet).isBerkeleyDB) {
|
||||||
|
err = "The new wallet file is not a valid wallet; your wallet was not changed.";
|
||||||
} else {
|
} else {
|
||||||
bool movedLegacy = false;
|
// Crash-safe order: wallet.dat is only ever REPLACED by one atomic rename of a complete,
|
||||||
|
// size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy
|
||||||
|
// wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the
|
||||||
|
// whole new wallet - never missing or half-written. Nothing of the user's is deleted.
|
||||||
|
const std::string staged = legacy + ".adopt-tmp";
|
||||||
|
auto sameSize = [](const std::string& a, const std::string& b) {
|
||||||
|
std::error_code e1, e2;
|
||||||
|
const auto sa = fs::file_size(a, e1);
|
||||||
|
const auto sb = fs::file_size(b, e2);
|
||||||
|
return !e1 && !e2 && sa == sb && sa > 0;
|
||||||
|
};
|
||||||
|
#ifndef _WIN32
|
||||||
|
auto syncFile = [](const std::string& p) { // best-effort flush to disk
|
||||||
|
int fd = ::open(p.c_str(), O_RDONLY);
|
||||||
|
if (fd >= 0) { ::fsync(fd); ::close(fd); }
|
||||||
|
};
|
||||||
|
#else
|
||||||
|
auto syncFile = [](const std::string&) {};
|
||||||
|
#endif
|
||||||
|
bool backupCreated = false;
|
||||||
|
// (a) Stage a full copy of the new wallet beside wallet.dat (same dir => atomic rename).
|
||||||
|
fs::copy_file(newWallet, staged, fs::copy_options::overwrite_existing, ec);
|
||||||
|
if (ec || !sameSize(newWallet, staged)) {
|
||||||
|
err = "Could not stage the new wallet; nothing was changed.";
|
||||||
|
} else {
|
||||||
|
syncFile(staged);
|
||||||
|
// (b) COPY (not rename) the legacy wallet to the timestamped .bak and verify it.
|
||||||
if (fs::exists(legacy)) {
|
if (fs::exists(legacy)) {
|
||||||
fs::rename(legacy, backup, ec);
|
backupCreated = !fs::exists(backup);
|
||||||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak
|
||||||
else movedLegacy = true;
|
if (ec || !sameSize(legacy, backup)) err = "Could not back up your current wallet; nothing was changed.";
|
||||||
|
else syncFile(backup);
|
||||||
}
|
}
|
||||||
|
// (c) Atomically replace wallet.dat with the staged wallet.
|
||||||
if (err.empty()) {
|
if (err.empty()) {
|
||||||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
fs::rename(staged, legacy, ec);
|
||||||
if (ec) {
|
if (ec) err = "Could not install the new wallet; your original wallet was left unchanged.";
|
||||||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
else swapDone = true;
|
||||||
err = "Could not install the new wallet; your original wallet was restored.";
|
|
||||||
} else {
|
|
||||||
swapDone = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!err.empty()) { // failed before the replace: original untouched; drop only OUR scratch files
|
||||||
|
std::error_code e3;
|
||||||
|
fs::remove(staged, e3);
|
||||||
|
if (backupCreated) fs::remove(backup, e3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6899,9 +7183,21 @@ void App::rebuildWalletDatabase()
|
|||||||
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
||||||
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
||||||
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
||||||
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
// runHiddenCapture runs this via popen("sh -c …") on POSIX, where $( ), backticks and an
|
||||||
|
// embedded \" still expand inside the double-quotes — so a crafted wallet filename (src is
|
||||||
|
// datadir + the activeWalletName setting) could inject a shell command. Refuse any path
|
||||||
|
// carrying a shell metacharacter; rc stays -1 and the verify-before-swap below fails safe.
|
||||||
|
auto shellSafe = [](const std::string& p) {
|
||||||
|
return p.find_first_of("\"'`$;&|<>^\n\r") == std::string::npos;
|
||||||
|
};
|
||||||
int rc = -1;
|
int rc = -1;
|
||||||
const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
|
std::string jout;
|
||||||
|
if (shellSafe(helper) && shellSafe(src) && shellSafe(tmpOut)) {
|
||||||
|
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
||||||
|
jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
|
||||||
|
} else {
|
||||||
|
DEBUG_LOGF("[App] wallet-rebuild refused: shell metacharacter in a path\n");
|
||||||
|
}
|
||||||
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
|
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
|
||||||
|
|
||||||
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
||||||
@@ -7015,6 +7311,7 @@ void App::pumpSeedMigration()
|
|||||||
seed_migration_dest_ = std::move(dest);
|
seed_migration_dest_ = std::move(dest);
|
||||||
seed_migration_temp_dir_ = std::move(tmp);
|
seed_migration_temp_dir_ = std::move(tmp);
|
||||||
seed_migration_backed_up_ = false;
|
seed_migration_backed_up_ = false;
|
||||||
|
seed_migration_understood_ = false;
|
||||||
seed_migration_step_ = SeedMigrationStep::ShowSeed;
|
seed_migration_step_ = SeedMigrationStep::ShowSeed;
|
||||||
// Persist the pending migration so a later sweep/adopt step can find the new wallet.
|
// Persist the pending migration so a later sweep/adopt step can find the new wallet.
|
||||||
if (settings_) {
|
if (settings_) {
|
||||||
@@ -7086,7 +7383,14 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
wallet::LiteSendRequest req;
|
wallet::LiteSendRequest req;
|
||||||
wallet::LiteSendRecipient recipient;
|
wallet::LiteSendRecipient recipient;
|
||||||
recipient.address = to;
|
recipient.address = to;
|
||||||
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0));
|
// Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a
|
||||||
|
// sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value.
|
||||||
|
std::uint64_t zat = 0;
|
||||||
|
if (!util::coinsToZatoshis(amount, zat)) {
|
||||||
|
if (callback) callback(false, "Invalid send amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipient.amountZatoshis = zat;
|
||||||
recipient.memo = memo;
|
recipient.memo = memo;
|
||||||
req.recipients.push_back(std::move(recipient));
|
req.recipients.push_back(std::move(recipient));
|
||||||
if (!lite_wallet_->sendTransaction(req)) {
|
if (!lite_wallet_->sendTransaction(req)) {
|
||||||
@@ -7170,6 +7474,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
||||||
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
|
||||||
std::string result_str;
|
std::string result_str;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
@@ -7178,12 +7483,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
ok = true;
|
ok = true;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
result_str = e.what();
|
result_str = e.what();
|
||||||
|
maybeInFlight = rpc::sendReplyMayBeLost(e);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
// Non-std throw must not escape into the worker — the main-thread callback
|
// Non-std throw must not escape into the worker — the main-thread callback
|
||||||
// below would never run, leaving a stuck "Sending…" spinner.
|
// below would never run, leaving a stuck "Sending…" spinner.
|
||||||
result_str = "Send failed (unknown error)";
|
result_str = "Send failed (unknown error)";
|
||||||
}
|
}
|
||||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
return [this, callback, ok, maybeInFlight, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||||
@@ -7206,7 +7512,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!background) send_progress_active_ = false;
|
if (!background) send_progress_active_ = false;
|
||||||
if (callback) callback(false, result_str);
|
if (maybeInFlight && !background) {
|
||||||
|
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
|
||||||
|
// distinct "check History before resending" outcome and re-read the wallet so a
|
||||||
|
// committed send shows up promptly. Definitive rejections stay plain failures below.
|
||||||
|
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
|
||||||
|
result_str.c_str());
|
||||||
|
markWalletMutated(/*forceBalance=*/true);
|
||||||
|
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
|
||||||
|
} else if (callback) {
|
||||||
|
callback(false, result_str);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -7391,7 +7707,7 @@ bool App::runtimeRescan(int startHeight)
|
|||||||
rescan_confirmed_active_ = true;
|
rescan_confirmed_active_ = true;
|
||||||
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
||||||
state_.sync.rescan_progress = 0.0f;
|
state_.sync.rescan_progress = 0.0f;
|
||||||
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
|
{ char rb[64]; snprintf(rb, sizeof(rb), TR("rescan_status_from_block"), startHeight); state_.sync.rescan_status = rb; }
|
||||||
markTransactionsDirty();
|
markTransactionsDirty();
|
||||||
last_tx_block_height_ = -1;
|
last_tx_block_height_ = -1;
|
||||||
invalidateShieldedHistoryScanProgress(true);
|
invalidateShieldedHistoryScanProgress(true);
|
||||||
|
|||||||
+70
-19
@@ -33,6 +33,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
@@ -476,6 +477,12 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
|
|||||||
lock_unlock_in_progress_ = false;
|
lock_unlock_in_progress_ = false;
|
||||||
lock_error_msg_.clear();
|
lock_error_msg_.clear();
|
||||||
lock_attempts_ = 0;
|
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_));
|
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||||
idle_.noteActivity();
|
idle_.noteActivity();
|
||||||
state_.encrypted = true;
|
state_.encrypted = true;
|
||||||
@@ -499,9 +506,37 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
|
|||||||
if (lock_attempts_ >= maxAttempts) {
|
if (lock_attempts_ >= maxAttempts) {
|
||||||
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
|
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_);
|
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
|
// 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.
|
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
|
||||||
void App::applyUnlockNotCounted(const std::string& msg) {
|
void App::applyUnlockNotCounted(const std::string& msg) {
|
||||||
@@ -513,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||||
|
if (lite_wallet_) { // lite: backend unlock with the shared attempt-lockout (no RPC/daemon)
|
||||||
|
std::string liteErr;
|
||||||
|
if (!unlockLiteWallet(passphrase, &liteErr)) {
|
||||||
|
lock_error_msg_ = liteErr;
|
||||||
|
lock_error_timer_ = 3.0f;
|
||||||
|
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
@@ -563,6 +607,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
|||||||
void App::lockWallet() {
|
void App::lockWallet() {
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
|
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
|
||||||
|
wipeDisplayedSecrets(); // hide any revealed seed/key immediately, before the async lock round-trip
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
// Use fast-lane worker to avoid blocking behind refreshData.
|
// Use fast-lane worker to avoid blocking behind refreshData.
|
||||||
@@ -755,12 +800,11 @@ void App::checkAutoLock() {
|
|||||||
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
||||||
if (!state_.isEncrypted() || state_.isLocked()) return;
|
if (!state_.isEncrypted() || state_.isLocked()) return;
|
||||||
|
|
||||||
// Don't auto-lock while mining — mining is a long-running intentional
|
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
|
||||||
// operation and locking the wallet on the daemon side stops solo mining.
|
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
|
||||||
bool miningActive = state_.mining.generate
|
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
|
||||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
if (state_.mining.generate) {
|
||||||
if (miningActive) {
|
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
|
||||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
|
||||||
idle_.noteActivity();
|
idle_.noteActivity();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1040,9 +1084,15 @@ void App::tickMiningThreadCommit()
|
|||||||
void App::renderLockScreen() {
|
void App::renderLockScreen() {
|
||||||
using namespace ui::material;
|
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
|
// 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.
|
// flight (its dots), the lockout countdown, an error message about to clear.
|
||||||
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f || lock_error_timer_ > 0.0f) {
|
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f) {
|
||||||
RequestProgressFrame();
|
RequestProgressFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,9 +1314,9 @@ void App::renderLockScreen() {
|
|||||||
lock_screen_was_visible_ = true;
|
lock_screen_was_visible_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error message
|
// Error message — stays visible until the next unlock attempt / mode switch / success, instead of
|
||||||
if (!lock_error_msg_.empty() && lock_error_timer_ > 0) {
|
// fading after a few seconds (a failed-unlock reason was often gone before it could be read).
|
||||||
lock_error_timer_ -= lockDt;
|
if (!lock_error_msg_.empty()) {
|
||||||
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
|
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
|
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
|
||||||
@@ -1312,6 +1362,7 @@ void App::renderLockScreen() {
|
|||||||
|
|
||||||
// Handle submit (Enter key or button click)
|
// Handle submit (Enter key or button click)
|
||||||
if ((submitted || btnClicked) && canSubmit) {
|
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:
|
// Derive daemon unlock timeout from auto-lock setting:
|
||||||
// 2x the idle timeout so the daemon stays unlocked longer than
|
// 2x the idle timeout so the daemon stays unlocked longer than
|
||||||
// the GUI auto-lock, acting as a safety net if the GUI crashes.
|
// the GUI auto-lock, acting as a safety net if the GUI crashes.
|
||||||
@@ -1505,7 +1556,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!encrypt_status_.empty()) {
|
if (!encrypt_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1516,7 +1567,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
||||||
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
||||||
: (!encMatch ? TR("enc_mismatch") : "");
|
: (!encMatch ? TR("enc_mismatch") : "");
|
||||||
if (*why) ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", why);
|
if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
|
||||||
}
|
}
|
||||||
|
|
||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
@@ -1600,7 +1651,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!enc_dlg_pin_status_.empty()) {
|
if (!enc_dlg_pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", enc_dlg_pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", enc_dlg_pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1718,7 +1769,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!encrypt_status_.empty()) {
|
if (!encrypt_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1799,7 +1850,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!decryptState.status.empty()) {
|
if (!decryptState.status.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str());
|
ImGui::TextColored(ui::material::ErrorVec4(), "%s", decryptState.status.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2047,7 +2098,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||||
// Completed
|
// Completed
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
||||||
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||||
// In progress - animate
|
// In progress - animate
|
||||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||||
@@ -2206,7 +2257,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2336,7 +2387,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2405,7 +2456,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
+4
-3
@@ -354,6 +354,9 @@ void App::clearDemoWalletData()
|
|||||||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||||||
seed_migration_precheck_started_ = false;
|
seed_migration_precheck_started_ = false;
|
||||||
seed_migration_backed_up_ = 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_ = 0.0;
|
||||||
seed_migration_balance_loaded_ = false;
|
seed_migration_balance_loaded_ = false;
|
||||||
seed_migration_nofunds_confirmed_ = false;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
@@ -567,8 +570,6 @@ void App::buildSweepCatalog()
|
|||||||
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
|
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
|
||||||
add("modal-about", ui::NavPage::Overview,
|
add("modal-about", ui::NavPage::Overview,
|
||||||
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
|
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
|
||||||
add("modal-settings", ui::NavPage::Settings,
|
|
||||||
[](App& a) { a.show_settings_ = true; }, [](App& a) { a.show_settings_ = false; });
|
|
||||||
|
|
||||||
// Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC.
|
// Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC.
|
||||||
add("modal-seed-backup", ui::NavPage::Overview,
|
add("modal-seed-backup", ui::NavPage::Overview,
|
||||||
@@ -623,7 +624,7 @@ void App::buildSweepCatalog()
|
|||||||
a.seed_migration_precheck_started_ = true;
|
a.seed_migration_precheck_started_ = true;
|
||||||
});
|
});
|
||||||
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
|
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,
|
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
|
||||||
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
|
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
|
||||||
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
|
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
|
||||||
|
|||||||
+63
-27
@@ -800,21 +800,22 @@ void App::renderFirstRunWizard() {
|
|||||||
// Daemon status indicator
|
// Daemon status indicator
|
||||||
{
|
{
|
||||||
bool daemonUp = isEmbeddedDaemonRunning();
|
bool daemonUp = isEmbeddedDaemonRunning();
|
||||||
const std::string& dStatus = getDaemonStatus();
|
// Key the dot + label off the daemon state ENUM, not an English substring of the status
|
||||||
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200) // green
|
// text — the substring match silently failed under every non-English language.
|
||||||
: IM_COL32(120, 120, 120, 160); // gray
|
const bool daemonStopping = daemon_controller_ &&
|
||||||
if (dStatus.find("Stopping") != std::string::npos)
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
|
||||||
dotCol = IM_COL32(255, 167, 38, 200); // orange
|
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;
|
float dotR = 3.5f * dp;
|
||||||
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
||||||
dotR, dotCol);
|
dotR, dotCol);
|
||||||
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
|
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
|
||||||
? TR("bootstrap_daemon_stopping")
|
: daemonUp ? TR("bootstrap_daemon_running")
|
||||||
: TR("bootstrap_daemon_running"))
|
|
||||||
: TR("bootstrap_daemon_stopped");
|
: TR("bootstrap_daemon_stopped");
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
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;
|
cy += captionFont->LegacySize + 6.0f * dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,14 +863,19 @@ void App::renderFirstRunWizard() {
|
|||||||
ui::material::Error(), TR("wiz_download_failed"));
|
ui::material::Error(), TR("wiz_download_failed"));
|
||||||
cy += bodyFont->LegacySize + 8.0f * dp;
|
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,
|
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol,
|
||||||
errMsg.c_str());
|
errMsg.c_str(), nullptr, contentW);
|
||||||
cy += captionFont->LegacySize + 16.0f * dp;
|
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 btnW2 = 120.0f * dp;
|
||||||
float btnH2 = 40.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;
|
float bx = rightX + (colW - totalBW) * 0.5f;
|
||||||
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(bx, cy));
|
ImGui::SetCursorScreenPos(ImVec2(bx, cy));
|
||||||
@@ -883,14 +889,33 @@ void App::renderFirstRunWizard() {
|
|||||||
stopDaemonForBootstrap();
|
stopDaemonForBootstrap();
|
||||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
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;
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
ImGui::PopStyleColor(3);
|
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);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||||
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
|
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
|
||||||
wizard_phase_ = WizardPhase::EncryptOffer;
|
wizard_phase_ = WizardPhase::EncryptOffer;
|
||||||
@@ -1014,6 +1039,15 @@ void App::renderFirstRunWizard() {
|
|||||||
cy += bsSize.y + 8.0f * dp;
|
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
|
// Trust warning
|
||||||
{
|
{
|
||||||
float warnOpacity = S.drawElement("screens.first-run", "trust-warning").opacity;
|
float warnOpacity = S.drawElement("screens.first-run", "trust-warning").opacity;
|
||||||
@@ -1031,21 +1065,20 @@ void App::renderFirstRunWizard() {
|
|||||||
// Daemon status indicator (subtle, before buttons)
|
// Daemon status indicator (subtle, before buttons)
|
||||||
{
|
{
|
||||||
bool daemonUp = isEmbeddedDaemonRunning();
|
bool daemonUp = isEmbeddedDaemonRunning();
|
||||||
const std::string& dStatus = getDaemonStatus();
|
const bool daemonStopping = daemon_controller_ &&
|
||||||
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200)
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
|
||||||
: IM_COL32(120, 120, 120, 160);
|
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230)
|
||||||
if (dStatus.find("Stopping") != std::string::npos)
|
: daemonUp ? IM_COL32(76, 175, 80, 230)
|
||||||
dotCol = IM_COL32(255, 167, 38, 200);
|
: IM_COL32(120, 120, 120, 180);
|
||||||
float dotR = 3.5f * dp;
|
float dotR = 3.5f * dp;
|
||||||
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
||||||
dotR, dotCol);
|
dotR, dotCol);
|
||||||
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
|
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
|
||||||
? TR("bootstrap_daemon_stopping")
|
: daemonUp ? TR("bootstrap_daemon_running")
|
||||||
: TR("bootstrap_daemon_running"))
|
|
||||||
: TR("bootstrap_daemon_stopped");
|
: TR("bootstrap_daemon_stopped");
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
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;
|
cy += captionFont->LegacySize + 10.0f * dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,6 +1104,7 @@ void App::renderFirstRunWizard() {
|
|||||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||||
bootstrap_->start(dataDir);
|
bootstrap_->start(dataDir);
|
||||||
|
wizard_bootstrap_url_.clear(); // default source; Retry won't force the mirror
|
||||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
@@ -1084,17 +1118,18 @@ void App::renderFirstRunWizard() {
|
|||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||||
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
|
if (ui::material::TactileButton(TR("bootstrap_use_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
|
||||||
stopDaemonForBootstrap();
|
stopDaemonForBootstrap();
|
||||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||||
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||||
bootstrap_->start(dataDir, mirrorUrl);
|
bootstrap_->start(dataDir, mirrorUrl);
|
||||||
|
wizard_bootstrap_url_ = mirrorUrl; // Retry on the failed card keeps this source
|
||||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
ui::material::Tooltip(TR("bootstrap_mirror_tooltip"));
|
ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
|
||||||
}
|
}
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
@@ -1421,7 +1456,8 @@ void App::renderFirstRunWizard() {
|
|||||||
wizard_phase_ = WizardPhase::Done;
|
wizard_phase_ = WizardPhase::Done;
|
||||||
settings_->setWizardCompleted(true);
|
settings_->setWizardCompleted(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
ui::Notifications::instance().info(TR("wiz_encrypt_bg"));
|
// The handoff toast is now emitted once at the completion point in app.cpp
|
||||||
|
// (wiz_handoff_encrypting), so don't also toast here.
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
|
|||||||
@@ -108,8 +108,18 @@ bool ChatDatabase::append(const ChatMessage& message)
|
|||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
std::string plain;
|
||||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
bool encrypted = false;
|
||||||
|
try {
|
||||||
|
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
|
||||||
|
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
|
||||||
|
plain = serialize(message);
|
||||||
|
encrypted = encrypt(plain, nonce, cipher);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
|
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
||||||
if (!encrypted) return false;
|
if (!encrypted) return false;
|
||||||
|
|
||||||
@@ -187,8 +197,16 @@ bool ChatDatabase::upsert(const ChatMessage& message)
|
|||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain = serialize(message);
|
std::string plain;
|
||||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
bool encrypted = false;
|
||||||
|
try {
|
||||||
|
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
|
||||||
|
encrypted = encrypt(plain, nonce, cipher);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
|
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
if (!encrypted) return false;
|
if (!encrypted) return false;
|
||||||
|
|
||||||
@@ -416,7 +434,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
|
|||||||
json["ts"] = message.timestamp;
|
json["ts"] = message.timestamp;
|
||||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||||
json["dl"] = static_cast<int>(message.delivery);
|
json["dl"] = static_cast<int>(message.delivery);
|
||||||
return json.dump();
|
return json.dump(); // strict: an invalid-UTF-8 body throws and is DROPPED by the callers (contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "chat_store.h"
|
#include "chat_store.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
|
|
||||||
@@ -112,6 +113,10 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ChatStore::clear() {
|
void ChatStore::clear() {
|
||||||
|
// Zero decrypted plaintext (message bodies + peer address) before dropping them — clear() runs on
|
||||||
|
// wallet lock / switch / teardown, and the freed std::strings would otherwise linger in the heap.
|
||||||
|
auto wipe = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
for (auto& m : messages_) { wipe(m.body); wipe(m.fail_reason); wipe(m.peer_zaddr); }
|
||||||
messages_.clear();
|
messages_.clear();
|
||||||
seen_.clear();
|
seen_.clear();
|
||||||
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
||||||
|
|||||||
@@ -271,6 +271,10 @@ bool Settings::load(const std::string& path)
|
|||||||
}
|
}
|
||||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||||
|
loadScalar(j, "chat_intro_dismissed", chat_intro_dismissed_);
|
||||||
|
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_);
|
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
||||||
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
||||||
empty_wallet_warning_acked_.clear();
|
empty_wallet_warning_acked_.clear();
|
||||||
@@ -596,6 +600,10 @@ bool Settings::save(const std::string& path)
|
|||||||
}
|
}
|
||||||
j["wizard_completed"] = wizard_completed_;
|
j["wizard_completed"] = wizard_completed_;
|
||||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||||
|
j["chat_intro_dismissed"] = chat_intro_dismissed_;
|
||||||
|
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["large_wallet_warned"] = large_wallet_warned_;
|
||||||
j["empty_wallet_warning_acked"] = json::array();
|
j["empty_wallet_warning_acked"] = json::array();
|
||||||
for (const auto& w : empty_wallet_warning_acked_)
|
for (const auto& w : empty_wallet_warning_acked_)
|
||||||
|
|||||||
@@ -392,6 +392,17 @@ public:
|
|||||||
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
||||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||||
|
bool getChatIntroDismissed() const { return chat_intro_dismissed_; }
|
||||||
|
void setChatIntroDismissed(bool v) { chat_intro_dismissed_ = v; durable_next_save_ = true; }
|
||||||
|
|
||||||
|
// 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).
|
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
||||||
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
||||||
@@ -736,6 +747,10 @@ private:
|
|||||||
std::map<std::string, AddressMeta> address_meta_;
|
std::map<std::string, AddressMeta> address_meta_;
|
||||||
bool wizard_completed_ = false;
|
bool wizard_completed_ = false;
|
||||||
bool seed_backup_reminded_ = false;
|
bool seed_backup_reminded_ = false;
|
||||||
|
bool chat_intro_dismissed_ = false; // one-time chat "how it works" intro card
|
||||||
|
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;
|
bool large_wallet_warned_ = false;
|
||||||
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
||||||
bool encryption_pending_ = false;
|
bool encryption_pending_ = false;
|
||||||
|
|||||||
@@ -703,8 +703,8 @@ bool EmbeddedDaemon::start(const std::string& binary_path, int lockWaitPolls)
|
|||||||
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||||
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||||
// string that would then be all getLastError()/the UI ever sees.
|
// string that would then be all getLastError()/the UI ever sees.
|
||||||
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
std::string detail = getLastError();
|
||||||
: last_error_;
|
if (detail.empty()) detail = "Failed to start dragonxd process";
|
||||||
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||||
setState(State::Error, detail);
|
setState(State::Error, detail);
|
||||||
return false;
|
return false;
|
||||||
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
char errBuf[256] = {0};
|
char errBuf[256] = {0};
|
||||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||||
last_error_ = "CreateProcess failed with error " + std::to_string(err) + ": " + errBuf;
|
setLastError("CreateProcess failed with error " + std::to_string(err) + ": " + errBuf);
|
||||||
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// Create pipe for stdout/stderr
|
// Create pipe for stdout/stderr
|
||||||
int pipefd[2];
|
int pipefd[2];
|
||||||
if (pipe(pipefd) == -1) {
|
if (pipe(pipefd) == -1) {
|
||||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create pipe: " + std::string(strerror(errno)));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// macOS, which has no pipe2().
|
// macOS, which has no pipe2().
|
||||||
int execpipe[2];
|
int execpipe[2];
|
||||||
if (pipe(execpipe) == -1) {
|
if (pipe(execpipe) == -1) {
|
||||||
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create exec-status pipe: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
return false;
|
return false;
|
||||||
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
setLastError("Fork failed: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
close(execpipe[0]);
|
close(execpipe[0]);
|
||||||
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
int status;
|
int status;
|
||||||
waitpid(pid, &status, 0);
|
waitpid(pid, &status, 0);
|
||||||
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||||
" — not executable or wrong architecture";
|
" — not executable or wrong architecture");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ private:
|
|||||||
std::atomic<bool> external_daemon_detected_{false};
|
std::atomic<bool> external_daemon_detected_{false};
|
||||||
std::string last_error_;
|
std::string last_error_;
|
||||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||||
|
// Locked writer for last_error_ (startProcess() runs on the caller thread while the monitor may call setState()).
|
||||||
|
void setLastError(std::string message) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(message); }
|
||||||
mutable std::mutex output_mutex_; // protects process_output_
|
mutable std::mutex output_mutex_; // protects process_output_
|
||||||
std::string process_output_;
|
std::string process_output_;
|
||||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||||
@@ -511,14 +513,14 @@ private:
|
|||||||
|
|
||||||
// Process handle
|
// Process handle
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE process_handle_ = nullptr;
|
std::atomic<HANDLE> process_handle_{nullptr}; // written by start/stop/monitor threads, read by accessors
|
||||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
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_binary_; // binary path used (for diagnostics)
|
||||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||||
#else
|
#else
|
||||||
pid_t process_pid_ = 0;
|
std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
|
||||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||||
void tailDebugLog();
|
void tailDebugLog();
|
||||||
|
|
||||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||||
|
|||||||
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
|||||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||||
|
|
||||||
|
// Reorg-safe depth before the adopt swap is enabled. Matches the History "confirmed" threshold
|
||||||
|
// (>= 10, data/transaction_history_store_policy.h): a 1-block reorg must not be able to un-mine the
|
||||||
|
// sweep after wallet.dat has been moved aside, which would strand the funds in the .bak.
|
||||||
|
inline constexpr int kSweepAdoptMinConfs = 10;
|
||||||
|
inline bool sweepConfsAreReorgSafe(int confs) { return confs >= kSweepAdoptMinConfs; }
|
||||||
|
|
||||||
|
// Final pre-stop re-verification, run on fresh RPC reads immediately before the daemon is stopped.
|
||||||
|
// needSweepTx : a sweep txid exists, so its confirmations must be reorg-safe (false = genuinely
|
||||||
|
// empty wallet, no sweep ever submitted).
|
||||||
|
// confs : fresh gettransaction confirmations (negative = conflicted).
|
||||||
|
// balanceMinconf0 : fresh z_gettotalbalance with minconf 0 (INCLUDES unconfirmed), < 0 = unknown.
|
||||||
|
// syncing : the node is still catching up, so balance reads are not trustworthy.
|
||||||
|
inline bool adoptReverifyPasses(bool needSweepTx, int confs, double balanceMinconf0,
|
||||||
|
bool syncing, double dust)
|
||||||
|
{
|
||||||
|
if (syncing) return false;
|
||||||
|
if (needSweepTx && !sweepConfsAreReorgSafe(confs)) return false;
|
||||||
|
return balanceMinconf0 >= 0.0 && balanceMinconf0 <= dust;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+34
-2
@@ -75,6 +75,13 @@ struct TransactionInfo {
|
|||||||
std::string getTypeDisplay() const;
|
std::string getTypeDisplay() const;
|
||||||
bool isConfirmed() const { return confirmations >= 1; }
|
bool isConfirmed() const { return confirmations >= 1; }
|
||||||
bool isMature() const { return confirmations >= 100; }
|
bool isMature() const { return confirmations >= 100; }
|
||||||
|
// gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out).
|
||||||
|
bool isConflicted() const { return confirmations < 0; }
|
||||||
|
// Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the
|
||||||
|
// mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged).
|
||||||
|
bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const {
|
||||||
|
return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
||||||
@@ -86,6 +93,15 @@ inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo&
|
|||||||
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
a.from_address == b.from_address && a.memo == b.memo;
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
}
|
}
|
||||||
|
// Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the
|
||||||
|
// History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the
|
||||||
|
// tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding.
|
||||||
|
inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) {
|
||||||
|
return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) &&
|
||||||
|
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) {
|
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
||||||
if (a.size() != b.size()) return false;
|
if (a.size() != b.size()) return false;
|
||||||
for (std::size_t i = 0; i < a.size(); ++i)
|
for (std::size_t i = 0; i < a.size(); ++i)
|
||||||
@@ -189,6 +205,11 @@ struct SyncInfo {
|
|||||||
bool tip_known = false;
|
bool tip_known = false;
|
||||||
|
|
||||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||||
|
|
||||||
|
// Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a
|
||||||
|
// peerless node has tip_known == false, so its local height can't be trusted as the network tip.
|
||||||
|
// The lite backend never populates tip_known, so lite is exempt (it has its own sync gate).
|
||||||
|
bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,6 +275,8 @@ struct PoolMiningState {
|
|||||||
int64_t uptime_sec = 0;
|
int64_t uptime_sec = 0;
|
||||||
double pool_diff = 0;
|
double pool_diff = 0;
|
||||||
bool connected = false;
|
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)
|
// Memory/thread usage (bytes for memory)
|
||||||
int64_t memory_used = 0;
|
int64_t memory_used = 0;
|
||||||
@@ -368,12 +391,21 @@ struct WalletState {
|
|||||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
// 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).
|
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||||
std::uint64_t tx_revision = 1;
|
std::uint64_t tx_revision = 1;
|
||||||
void touchTransactions() { ++tx_revision; }
|
// Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's
|
||||||
|
// O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that
|
||||||
|
// is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default).
|
||||||
|
std::uint64_t tx_structure_revision = 1;
|
||||||
|
void touchTransactions() { ++tx_revision; ++tx_structure_revision; }
|
||||||
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
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.
|
// 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) {
|
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||||
if (sameTransactionList(transactions, next)) return false;
|
if (sameTransactionList(transactions, next)) return false;
|
||||||
replaceTransactions(std::move(next));
|
bool layoutSame = transactions.size() == next.size();
|
||||||
|
for (std::size_t i = 0; layoutSame && i < next.size(); ++i)
|
||||||
|
layoutSame = sameTransactionLayout(transactions[i], next[i]);
|
||||||
|
transactions = std::move(next);
|
||||||
|
++tx_revision;
|
||||||
|
if (!layoutSame) ++tx_structure_revision;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
int64_t last_peer_update = 0;
|
int64_t last_peer_update = 0;
|
||||||
|
|||||||
+7
-16
@@ -460,11 +460,10 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ofstream file(path);
|
// Build the content in memory, then write it 0600 from the start (writeFileAtomically with
|
||||||
if (!file.is_open()) {
|
// restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
|
||||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
// another local user could read the freshly-generated rpcuser/rpcpassword.
|
||||||
return false;
|
std::ostringstream file;
|
||||||
}
|
|
||||||
|
|
||||||
// Get the data directory for exportdir
|
// Get the data directory for exportdir
|
||||||
std::string dataDir = getDefaultDataDir();
|
std::string dataDir = getDefaultDataDir();
|
||||||
@@ -486,17 +485,9 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
file << "addnode=node1.dragonx.is\n";
|
file << "addnode=node1.dragonx.is\n";
|
||||||
file << "addnode=node5.dragonx.is\n";
|
file << "addnode=node5.dragonx.is\n";
|
||||||
|
|
||||||
file.close();
|
if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
|
||||||
|
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||||
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
|
return false;
|
||||||
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
|
|
||||||
// before another local user can read the credentials.
|
|
||||||
{
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
std::error_code ec;
|
|
||||||
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
|
|
||||||
fs::perm_options::replace, ec);
|
|
||||||
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
||||||
|
|||||||
+16
-1
@@ -216,7 +216,8 @@ bool RPCClient::open(const std::string& host, const std::string& port,
|
|||||||
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
||||||
clearAbort(); // a fresh connection must not start in the aborted state
|
// NOTE: abort is deliberately NOT cleared here: a requestAbort() issued while this connect was queued
|
||||||
|
// must still cut it short. Reconnect callers (App::tryConnect / onConnected) call clearAbort() first.
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
||||||
@@ -370,6 +371,20 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
|||||||
|
|
||||||
json payload = makePayload(method, params);
|
json payload = makePayload(method, params);
|
||||||
std::string body = payload.dump();
|
std::string body = payload.dump();
|
||||||
|
// Request-side secret scrub (responses are already scrubbed): the body + payload tree can carry
|
||||||
|
// walletpassphrase / encryptwallet / *_importkey args. Wipe them when this scope ends (all return
|
||||||
|
// and throw paths) and detach CURLOPT_POSTFIELDS so the reused handle holds no dangling pointer.
|
||||||
|
struct RequestScrubGuard {
|
||||||
|
CURL* curl; std::string& body; json& payload;
|
||||||
|
~RequestScrubGuard() {
|
||||||
|
if (!body.empty()) sodium_memzero(&body[0], body.size());
|
||||||
|
scrubJsonSecrets(payload);
|
||||||
|
if (curl) {
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, nullptr);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} requestScrubGuard{impl_->curl, body, payload};
|
||||||
std::string response_data;
|
std::string response_data;
|
||||||
|
|
||||||
// Set POST data
|
// Set POST data
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
|||||||
return RpcFailureKind::Other;
|
return RpcFailureKind::Other;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Did a failed z_sendmany (or any submit call) possibly reach the node and take effect? True only for a
|
||||||
|
// transport failure AFTER the request left us with no HTTP status back (reply timeout, connection cut
|
||||||
|
// mid-reply, our own abort): the daemon may already hold the operation, so the caller must not invite an
|
||||||
|
// immediate resend. Definitive answers/rejections (AppError, QueueFull, Auth, Warmup, Deferred,
|
||||||
|
// NotListening, ConnectTimeout, any HTTP status) mean nothing was queued and stay plain failures.
|
||||||
|
inline bool sendReplyMayBeLost(const std::exception& e)
|
||||||
|
{
|
||||||
|
const auto* t = dynamic_cast<const RpcTransportError*>(&e);
|
||||||
|
return t && t->requestSent && t->httpStatus == 0 && t->curlCode != CURLE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lite broadcast errors are plain backend text (tonic/gRPC). Ambiguous = a transport-style failure while
|
||||||
|
// talking to the lightwalletd server (the server may have accepted the tx before the reply was lost).
|
||||||
|
// Connect-phase failures and an explicit server rejection ("error_code") are definitive. Heuristic by
|
||||||
|
// design: a false positive only shows a more cautious message, never changes the send.
|
||||||
|
inline bool liteSendErrorMayBeInFlight(const std::string& err)
|
||||||
|
{
|
||||||
|
std::string lc = err;
|
||||||
|
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
auto has = [&](const char* n) { return lc.find(n) != std::string::npos; };
|
||||||
|
if (has("error_code") || has("error trying to connect") || has("connection refused") ||
|
||||||
|
has("dns error") || has("insufficient") || has("locked"))
|
||||||
|
return false;
|
||||||
|
return has("timed out") || has("timeout") || has("deadline") || has("transport error") ||
|
||||||
|
has("connection reset") || has("broken pipe") || has("h2 protocol error") ||
|
||||||
|
has("unexpected eof") || has("status: unknown") || has("status: cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag carried in the failure text of a send callback (bool, string) so the Send tab can show the distinct
|
||||||
|
// "may have been broadcast - check History before resending" outcome without changing the callback type.
|
||||||
|
// The prefix is readable English so callers that just print the text still say something accurate.
|
||||||
|
inline const char* sendMaybeInFlightPrefix()
|
||||||
|
{
|
||||||
|
return "Send status unknown - it may already have been broadcast: ";
|
||||||
|
}
|
||||||
|
inline bool isSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return s.rfind(sendMaybeInFlightPrefix(), 0) == 0;
|
||||||
|
}
|
||||||
|
inline std::string markSendMaybeInFlight(const std::string& detail)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(detail) ? detail : std::string(sendMaybeInFlightPrefix()) + detail;
|
||||||
|
}
|
||||||
|
inline std::string stripSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(s) ? s.substr(std::string(sendMaybeInFlightPrefix()).size()) : s;
|
||||||
|
}
|
||||||
|
|
||||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
// 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.
|
// 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 };
|
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "rpc/rpc_busy_gate.h"
|
#include "rpc/rpc_busy_gate.h"
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
||||||
|
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -34,7 +35,20 @@ std::optional<T> readOptional(const json& source, const char* key)
|
|||||||
std::optional<double> readBalanceString(const json& source, const char* key)
|
std::optional<double> readBalanceString(const json& source, const char* key)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (source.contains(key)) return std::stod(source[key].get<std::string>());
|
if (source.contains(key)) {
|
||||||
|
const std::string text = source[key].get<std::string>();
|
||||||
|
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
|
||||||
|
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
|
||||||
|
if (text.empty()) return std::nullopt;
|
||||||
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||||
|
const char c = text[i];
|
||||||
|
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
const double value = std::strtod(text.c_str(), &end);
|
||||||
|
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
@@ -52,15 +66,17 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||||
std::map<std::string, double> total;
|
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
|
||||||
std::map<std::string, double> spendable;
|
std::map<std::string, std::int64_t> total;
|
||||||
|
std::map<std::string, std::int64_t> spendable;
|
||||||
for (const auto& output : unspent) {
|
for (const auto& output : unspent) {
|
||||||
auto address = readOptional<std::string>(output, "address");
|
auto address = readOptional<std::string>(output, "address");
|
||||||
auto amount = readOptional<double>(output, "amount");
|
auto amount = readOptional<double>(output, "amount");
|
||||||
if (!address || !amount) continue;
|
if (!address || !amount || !std::isfinite(*amount)) continue;
|
||||||
total[*address] += *amount;
|
const std::int64_t sats = util::toSats(*amount);
|
||||||
|
total[*address] += sats;
|
||||||
auto conf = readOptional<int>(output, "confirmations");
|
auto conf = readOptional<int>(output, "confirmations");
|
||||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
if (conf && *conf >= 1) spendable[*address] += sats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||||
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
for (auto& info : addresses) {
|
for (auto& info : addresses) {
|
||||||
auto t = total.find(info.address);
|
auto t = total.find(info.address);
|
||||||
auto s = spendable.find(info.address);
|
auto s = spendable.find(info.address);
|
||||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
|
||||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
|
|||||||
|
|
||||||
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry)
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight)
|
||||||
{
|
{
|
||||||
|
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
|
||||||
|
int entryConfirmations = entry.confirmations;
|
||||||
|
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
|
||||||
|
entryConfirmations += currentHeight - entry.observed_height;
|
||||||
for (const auto& out : entry.outgoing_outputs) {
|
for (const auto& out : entry.outgoing_outputs) {
|
||||||
bool alreadyTracked = false;
|
bool alreadyTracked = false;
|
||||||
for (const auto& existing : transactions) {
|
for (const auto& existing : transactions) {
|
||||||
@@ -1073,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!foundExistingMetadata) {
|
if (!foundExistingMetadata) {
|
||||||
info.confirmations = entry.confirmations;
|
info.confirmations = entryConfirmations;
|
||||||
info.timestamp = entry.timestamp;
|
info.timestamp = entry.timestamp;
|
||||||
}
|
}
|
||||||
transactions.push_back(std::move(info));
|
transactions.push_back(std::move(info));
|
||||||
@@ -1460,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
auto cached = snapshot.viewTxCache.find(txid);
|
auto cached = snapshot.viewTxCache.find(txid);
|
||||||
if (cached != snapshot.viewTxCache.end()) {
|
if (cached != snapshot.viewTxCache.end()) {
|
||||||
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
||||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
|
||||||
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
||||||
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
||||||
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
||||||
@@ -1493,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||||
|
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
|
||||||
const bool storedRow = rowBacklog.count(txid) > 0;
|
const bool storedRow = rowBacklog.count(txid) > 0;
|
||||||
if (storedRow && entry.timestamp == 0) {
|
if (storedRow && entry.timestamp == 0) {
|
||||||
const auto settled = settledStoredMeta.find(txid);
|
const auto settled = settledStoredMeta.find(txid);
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ public:
|
|||||||
std::string from_address;
|
std::string from_address;
|
||||||
std::int64_t timestamp = 0;
|
std::int64_t timestamp = 0;
|
||||||
int confirmations = 0;
|
int confirmations = 0;
|
||||||
|
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
|
||||||
struct Output {
|
struct Output {
|
||||||
std::string address;
|
std::string address;
|
||||||
double value = 0.0;
|
double value = 0.0;
|
||||||
@@ -427,9 +428,12 @@ public:
|
|||||||
const std::set<std::string>& miningAddresses = {},
|
const std::set<std::string>& miningAddresses = {},
|
||||||
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
||||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||||
|
// currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports
|
||||||
|
// confirmations advanced by the blocks added since it was observed (display only).
|
||||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry);
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight = -1);
|
||||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
// 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
|
// (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).
|
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_ = openedDb;
|
db_ = openedDb;
|
||||||
|
++revision_;
|
||||||
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
||||||
// freezing the frame for 2 s.
|
// freezing the frame for 2 s.
|
||||||
sqlite3_busy_timeout(db_, 250);
|
sqlite3_busy_timeout(db_, 250);
|
||||||
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::map<int, ExplorerBlockSummary>& ExplorerBlockCache::loadRangeCached(int minHeight, int maxHeight)
|
||||||
|
{
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
if (range_cache_.size() >= 16) range_cache_.clear();
|
||||||
|
const std::pair<int, int> key(minHeight, maxHeight);
|
||||||
|
auto it = range_cache_.find(key);
|
||||||
|
if (it == range_cache_.end()) {
|
||||||
|
auto loaded = loadRange(minHeight, maxHeight); // may open the DB (bumps revision_)
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
it = range_cache_.emplace(key, std::move(loaded)).first;
|
||||||
|
}
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||||
{
|
{
|
||||||
std::vector<ExplorerBlockSummary> results;
|
std::vector<ExplorerBlockSummary> results;
|
||||||
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
|
|||||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||||
{
|
{
|
||||||
if (block.height < 1 || block.hash.empty()) return false;
|
if (block.height < 1 || block.hash.empty()) return false;
|
||||||
|
++revision_;
|
||||||
const bool stored = storeBlockRow(block);
|
const bool stored = storeBlockRow(block);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
spill_.erase(block.height);
|
spill_.erase(block.height);
|
||||||
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
|
|||||||
|
|
||||||
void ExplorerBlockCache::clearBlocks()
|
void ExplorerBlockCache::clearBlocks()
|
||||||
{
|
{
|
||||||
|
++revision_;
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
if (!ensureOpen()) return;
|
if (!ensureOpen()) return;
|
||||||
exec("DELETE FROM explorer_blocks");
|
exec("DELETE FROM explorer_blocks");
|
||||||
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
|
|||||||
void ExplorerBlockCache::pruneTo(int maxRows)
|
void ExplorerBlockCache::pruneTo(int maxRows)
|
||||||
{
|
{
|
||||||
if (maxRows < 1 || !db_) return;
|
if (maxRows < 1 || !db_) return;
|
||||||
|
++revision_;
|
||||||
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
||||||
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
||||||
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
||||||
@@ -333,8 +351,10 @@ ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
|||||||
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
||||||
if (!spill_.empty() && !currentBestHash.empty() &&
|
if (!spill_.empty() && !currentBestHash.empty() &&
|
||||||
(currentHeight < spill_tip_height_ ||
|
(currentHeight < spill_tip_height_ ||
|
||||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
|
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
|
++revision_;
|
||||||
|
}
|
||||||
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
||||||
return validation;
|
return validation;
|
||||||
}
|
}
|
||||||
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
|
|||||||
{
|
{
|
||||||
tip_known_ = false;
|
tip_known_ = false;
|
||||||
if (!db_) return;
|
if (!db_) return;
|
||||||
|
++revision_;
|
||||||
sqlite3_close(db_);
|
sqlite3_close(db_);
|
||||||
db_ = nullptr;
|
db_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct sqlite3;
|
struct sqlite3;
|
||||||
@@ -44,6 +45,11 @@ public:
|
|||||||
const std::string& databasePath() const { return database_path_; }
|
const std::string& databasePath() const { return database_path_; }
|
||||||
|
|
||||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||||
|
// loadRange memoized per (min,max) and invalidated by revision() so the per-frame Explorer list only hits SQLite
|
||||||
|
// after the cache actually changed (store / clear / prune / spill reset / open / close). The reference is valid
|
||||||
|
// until the next call; copy it if you call again before using it.
|
||||||
|
const std::map<int, ExplorerBlockSummary>& loadRangeCached(int minHeight, int maxHeight);
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
||||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||||
@@ -102,6 +108,11 @@ private:
|
|||||||
int open_attempts_ = 0;
|
int open_attempts_ = 0;
|
||||||
int stores_since_prune_ = 0;
|
int stores_since_prune_ = 0;
|
||||||
|
|
||||||
|
// Bumped by every mutation that can change what loadRange returns; keys the loadRangeCached memo.
|
||||||
|
std::uint64_t revision_ = 1;
|
||||||
|
std::uint64_t range_cache_revision_ = 0;
|
||||||
|
std::map<std::pair<int, int>, std::map<int, ExplorerBlockSummary>> range_cache_;
|
||||||
|
|
||||||
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
||||||
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
||||||
static constexpr std::size_t kMaxSpillBlocks = 512;
|
static constexpr std::size_t kMaxSpillBlocks = 512;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Best-effort scrub of ImGui's internal InputText buffers, which retain un-zeroed copies of typed
|
||||||
|
// secrets (wallet passphrase / PIN / seed / private key) after a field is edited — the app's own
|
||||||
|
// char[] buffers are memzeroed, but ImGui also copies the text into its global edit/undo/deactivated
|
||||||
|
// state, which it only frees (not zeroes).
|
||||||
|
//
|
||||||
|
// This reaches into vendored imgui_internal state, so it is PINNED to the vendored ImGui version: a
|
||||||
|
// bump trips the #error below and forces a re-audit of the member names. It is BEST-EFFORT only —
|
||||||
|
// std::-style buffer reallocation, the system clipboard, and OS/IME layers can still hold copies, so
|
||||||
|
// this shrinks the exposure window but does not guarantee absence of secret material in a core dump.
|
||||||
|
|
||||||
|
#include "imgui.h"
|
||||||
|
#include "imgui_internal.h"
|
||||||
|
#include <sodium.h>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#if IMGUI_VERSION_NUM != 19259
|
||||||
|
#error "imgui_secret_scrub.h is pinned to ImGui 1.92.6 (IMGUI_VERSION_NUM 19259). After bumping libs/imgui, re-audit ImGuiInputTextState / ImGuiInputTextDeactivatedState / ImGuiContext member names below, then update this guard."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace ui {
|
||||||
|
|
||||||
|
// Zero ImGui's retained InputText buffers. Safe to call at a lock transition / when a secret dialog
|
||||||
|
// closes (it defocuses the active field). Do NOT call while a secret field is actively being typed.
|
||||||
|
inline void scrubImGuiInputTextState() {
|
||||||
|
ImGuiContext* ctx = ImGui::GetCurrentContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
ImGuiInputTextState& st = ctx->InputTextState;
|
||||||
|
// Defocus first so the next frame can't re-read the buffer we're about to zero (ClearActiveID also
|
||||||
|
// refreshes the deactivated-state copy, which we then zero below).
|
||||||
|
if (st.ID != 0 && ctx->ActiveId == st.ID) ImGui::ClearActiveID();
|
||||||
|
auto zeroVec = [](ImVector<char>& v) {
|
||||||
|
if (v.Data && v.Size > 0) sodium_memzero(v.Data, static_cast<size_t>(v.Size));
|
||||||
|
};
|
||||||
|
zeroVec(st.TextA); // the live edit buffer
|
||||||
|
zeroVec(st.TextToRevertTo); // Escape-to-revert backup
|
||||||
|
zeroVec(st.CallbackTextBackup); // callback reconcile scratch
|
||||||
|
st.TextLen = 0;
|
||||||
|
// NOTE: st.Stb (the stb_textedit undo/redo char buffers) is deliberately NOT zeroed here — its type
|
||||||
|
// ImStbTexteditState is incomplete in imgui_internal.h (fully defined only inside ImGui's .cpp), so we
|
||||||
|
// can't sizeof it from a header. The primary copies above cover the committed text; the undo scratch
|
||||||
|
// is a residual gap in this best-effort scrub.
|
||||||
|
zeroVec(ctx->InputTextDeactivatedState.TextA); // the just-deactivated copy
|
||||||
|
ctx->InputTextDeactivatedState.ID = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui
|
||||||
|
} // namespace dragonx
|
||||||
@@ -22,6 +22,8 @@ enum class NodeBannerAction {
|
|||||||
None, // no button — nothing the user can usefully do from here
|
None, // no button — nothing the user can usefully do from here
|
||||||
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||||
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||||
|
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
||||||
|
RecoverLegacyFunds, // a degraded legacy wallet with stuck transparent funds: open migrate-to-seed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||||
@@ -33,6 +35,9 @@ enum class NodeBannerReason {
|
|||||||
LiteOpenFailed, // lite build: the wallet failed to open
|
LiteOpenFailed, // lite build: the wallet failed to open
|
||||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||||
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
||||||
|
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
||||||
|
LegacyFundsStuck, // a DEGRADED legacy wallet (can't derive a z-address) holding transparent funds it
|
||||||
|
// therefore can't shield/spend — offer the migrate-to-seed recovery
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NodeBannerState {
|
struct NodeBannerState {
|
||||||
@@ -71,6 +76,13 @@ struct NodeBannerInputs {
|
|||||||
bool node_busy = false;
|
bool node_busy = false;
|
||||||
std::string node_busy_detail;
|
std::string node_busy_detail;
|
||||||
|
|
||||||
|
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
||||||
|
|
||||||
|
// The wallet opened DEGRADED (no usable HD seed, so z_getnewaddress / shielding fail) AND it holds
|
||||||
|
// transparent funds that are therefore stuck. App sets this ONLY from wallet_degraded_ + a transparent
|
||||||
|
// balance — never from merely-non-mnemonic status, which is a functional wallet and must not be flagged.
|
||||||
|
bool legacy_funds_stuck = false;
|
||||||
|
|
||||||
std::string connection_status; // human-readable status line (already translated)
|
std::string connection_status; // human-readable status line (already translated)
|
||||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||||
@@ -130,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
? NodeBannerAction::RestartNode
|
? NodeBannerAction::RestartNode
|
||||||
: NodeBannerAction::None;
|
: NodeBannerAction::None;
|
||||||
s.detail.clear();
|
s.detail.clear();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Connected and synced, but a degraded legacy wallet can't shield its transparent funds. Offer the
|
||||||
|
// recovery flow. Checked after NoPeers (can't recover until the node can see the chain anyway).
|
||||||
|
if (in.legacy_funds_stuck) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::LegacyFundsStuck;
|
||||||
|
s.action = NodeBannerAction::RecoverLegacyFunds;
|
||||||
|
s.detail.clear();
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
@@ -146,6 +168,21 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The node rejected the wallet's RPC login — retrying won't help (the creds are wrong). Restarting
|
||||||
|
// the embedded node regenerates its cookie/creds; for an external node the user fixes DRAGONX.conf,
|
||||||
|
// so offer to open its folder. App renders a translated, actionable detail (connection_status here
|
||||||
|
// is the jargon "Auth failed…" line).
|
||||||
|
if (in.auth_failed) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Error;
|
||||||
|
s.reason = NodeBannerReason::AuthFailed;
|
||||||
|
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
|
||||||
|
? NodeBannerAction::RestartNode
|
||||||
|
: NodeBannerAction::OpenConfigFolder;
|
||||||
|
s.detail.clear();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
s.show = true;
|
s.show = true;
|
||||||
s.severity = NodeBannerSeverity::Warning;
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
s.reason = NodeBannerReason::FullNodeOffline;
|
s.reason = NodeBannerReason::FullNodeOffline;
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ void Notifications::render()
|
|||||||
float iconGap = 4.0f * dp;
|
float iconGap = 4.0f * dp;
|
||||||
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
||||||
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
||||||
|
// "+N more" badge when several live toasts are queued (render shows only the newest).
|
||||||
|
const size_t moreCount = hiddenLiveCount();
|
||||||
|
std::string badge = moreCount ? ("+" + std::to_string(moreCount)) : std::string();
|
||||||
|
float badgeW = 0.0f, badgeGap = 0.0f;
|
||||||
|
if (!badge.empty() && textFont) {
|
||||||
|
badgeW = textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, badge.c_str()).x + 10.0f * dp;
|
||||||
|
badgeGap = 6.0f * dp;
|
||||||
|
pillWidth += badgeW + badgeGap;
|
||||||
|
}
|
||||||
// Clamp to reasonable bounds
|
// Clamp to reasonable bounds
|
||||||
float maxPillW = viewport->WorkSize.x * 0.5f;
|
float maxPillW = viewport->WorkSize.x * 0.5f;
|
||||||
if (pillWidth > maxPillW) pillWidth = maxPillW;
|
if (pillWidth > maxPillW) pillWidth = maxPillW;
|
||||||
@@ -123,9 +132,16 @@ void Notifications::render()
|
|||||||
glassSpec.borderWidth = 1.0f;
|
glassSpec.borderWidth = 1.0f;
|
||||||
material::DrawGlassPanel(dl, pMin, pMax, glassSpec);
|
material::DrawGlassPanel(dl, pMin, pMax, glassSpec);
|
||||||
|
|
||||||
|
// Severity-tinted fill so the toast differs by more than hue (helps when color alone is hard to read).
|
||||||
|
{
|
||||||
|
ImVec4 tint = accent_color; // already premultiplied by fade `alpha`
|
||||||
|
tint.w *= nde("accent-fill-alpha", 0.22f);
|
||||||
|
dl->AddRectFilled(pMin, pMax, ImGui::ColorConvertFloat4ToU32(tint), pillRounding);
|
||||||
|
}
|
||||||
|
|
||||||
// Colored accent border on top of the glass border for type indication
|
// Colored accent border on top of the glass border for type indication
|
||||||
ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(accent_color);
|
ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(accent_color);
|
||||||
dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.0f);
|
dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.5f * dp);
|
||||||
|
|
||||||
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
||||||
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
||||||
@@ -156,7 +172,7 @@ void Notifications::render()
|
|||||||
bool clipped = false;
|
bool clipped = false;
|
||||||
if (textFont) {
|
if (textFont) {
|
||||||
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
|
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
|
||||||
const float availTextW = (pMax.x - pillPadX) - cursorX;
|
const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap));
|
||||||
std::string shown = notif.message;
|
std::string shown = notif.message;
|
||||||
if (availTextW > 0.0f &&
|
if (availTextW > 0.0f &&
|
||||||
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
|
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
|
||||||
@@ -176,8 +192,23 @@ void Notifications::render()
|
|||||||
dl->PopClipRect();
|
dl->PopClipRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped.
|
// "+N more" badge, right-aligned inside the pill.
|
||||||
|
if (!badge.empty() && textFont) {
|
||||||
|
float badgeX = pMax.x - pillPadX - badgeW;
|
||||||
|
ImVec4 bf = accent_color; bf.w *= nde("badge-fill-alpha", 0.35f);
|
||||||
|
dl->AddRectFilled(ImVec2(badgeX, pillY + 3.0f * dp), ImVec2(badgeX + badgeW, pMax.y - 3.0f * dp),
|
||||||
|
ImGui::ColorConvertFloat4ToU32(bf), (pillHeight - 6.0f * dp) * 0.5f);
|
||||||
|
dl->AddText(textFont, textFont->LegacySize, ImVec2(badgeX + 5.0f * dp, contentY),
|
||||||
|
ImGui::ColorConvertFloat4ToU32(text_color), badge.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover: pause the auto-dismiss (next frame), reveal the full message if clipped, and allow a click to
|
||||||
|
// open the notification history (the status bar consumes the request).
|
||||||
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
|
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
|
||||||
|
if (toast_hovered_) {
|
||||||
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
|
||||||
|
}
|
||||||
if (toast_hovered_ && clipped)
|
if (toast_hovered_ && clipped)
|
||||||
ImGui::SetTooltip("%s", notif.message.c_str());
|
ImGui::SetTooltip("%s", notif.message.c_str());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,8 +169,20 @@ public:
|
|||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
notifications_.clear();
|
notifications_.clear();
|
||||||
|
history_requested_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of currently-live toasts beyond the one the pill shows (for a "+N more" badge).
|
||||||
|
size_t hiddenLiveCount() const {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
size_t live = 0;
|
||||||
|
for (const auto& n : notifications_) if (!n.isExpiredAt(now)) ++live;
|
||||||
|
return live > 1 ? live - 1 : 0;
|
||||||
|
}
|
||||||
|
/// render() sets this when the toast is clicked; the status bar consumes it to open the history panel.
|
||||||
|
void requestHistory() { history_requested_ = true; }
|
||||||
|
bool consumeHistoryRequest() { bool r = history_requested_; history_requested_ = false; return r; }
|
||||||
|
|
||||||
void setMaxNotifications(size_t max) {
|
void setMaxNotifications(size_t max) {
|
||||||
max_notifications_ = max;
|
max_notifications_ = max;
|
||||||
}
|
}
|
||||||
@@ -191,6 +203,7 @@ private:
|
|||||||
Notifications& operator=(const Notifications&) = delete;
|
Notifications& operator=(const Notifications&) = delete;
|
||||||
|
|
||||||
std::deque<Notification> notifications_;
|
std::deque<Notification> notifications_;
|
||||||
|
bool history_requested_ = false; // set on toast click, consumed by the status bar to open history
|
||||||
size_t max_notifications_ = 5;
|
size_t max_notifications_ = 5;
|
||||||
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
|
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
|
||||||
std::chrono::steady_clock::time_point last_drawn_{};
|
std::chrono::steady_clock::time_point last_drawn_{};
|
||||||
|
|||||||
+109
-53
@@ -869,7 +869,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (skin.bundled) continue;
|
if (skin.bundled) continue;
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
||||||
if (!skin.valid) {
|
if (!skin.valid) {
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::ErrorVec4());
|
||||||
ImGui::BeginDisabled(true);
|
ImGui::BeginDisabled(true);
|
||||||
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
||||||
ImGui::Selectable(lbl.c_str(), false);
|
ImGui::Selectable(lbl.c_str(), false);
|
||||||
@@ -904,7 +904,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
float cellGap = Layout::spacingLg();
|
float cellGap = Layout::spacingLg();
|
||||||
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
|
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
|
||||||
int cols = twoCol ? 2 : 1;
|
int cols = twoCol ? 2 : 1;
|
||||||
float colW = std::max(160.0f, twoCol ? (contentW - cellGap) * 0.5f : contentW);
|
float colW = std::max(160.0f * dp, twoCol ? (contentW - cellGap) * 0.5f : contentW);
|
||||||
float baseX = ImGui::GetCursorScreenPos().x;
|
float baseX = ImGui::GetCursorScreenPos().x;
|
||||||
|
|
||||||
const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") };
|
const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") };
|
||||||
@@ -1346,7 +1346,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(cx, last + gp));
|
ImGui::SetCursorScreenPos(ImVec2(cx, last + gp));
|
||||||
ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
|
ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
|
||||||
if (app->daemonAutoShieldActive()) {
|
if (app->daemonAutoShieldActive()) {
|
||||||
ImGui::TextColored(ImVec4(0.40f, 0.78f, 0.40f, 1.0f), "%s", TR("autoshield_by_node"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("autoshield_by_node"));
|
||||||
if (!app->daemonAutoShieldAddress().empty())
|
if (!app->daemonAutoShieldAddress().empty())
|
||||||
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
|
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
|
||||||
} else {
|
} else {
|
||||||
@@ -1490,6 +1490,9 @@ void RenderSettingsPage(App* app) {
|
|||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
||||||
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
if (cardBtn(cx, cy, cw, "##imp_file", TR("settings_import_wallet_file"), ICON_MD_UPLOAD_FILE)) app->showImportWalletFileDialog();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_wallet_file"));
|
||||||
last = cy + bh;
|
last = cy + bh;
|
||||||
if (fullNode) {
|
if (fullNode) {
|
||||||
cy += bh + bgp;
|
cy += bh + bgp;
|
||||||
@@ -1548,9 +1551,13 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
|
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
|
||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
// Export-all only works over the full-node RPC; hide it in lite (which has its own key export
|
||||||
|
// in the lite wallet section) rather than dead-ending on "Not connected to the daemon".
|
||||||
|
if (fullNode) {
|
||||||
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
|
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
|
||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
}
|
||||||
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
|
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
|
||||||
last = cy + bh;
|
last = cy + bh;
|
||||||
@@ -1645,20 +1652,20 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SameLine(0, Layout::spacingMd());
|
ImGui::SameLine(0, Layout::spacingMd());
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK);
|
ImGui::TextColored(material::WarningVec4(), ICON_MD_LOCK);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("settings_locked"));
|
||||||
} else {
|
} else {
|
||||||
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||||
app->lockWallet();
|
app->lockWallet();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
||||||
ImGui::SameLine(0, Layout::spacingSm());
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
|
ImGui::TextColored(material::SuccessVec4(), ICON_MD_LOCK_OPEN);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_unlocked"));
|
||||||
}
|
}
|
||||||
// Remove Encryption button — trails the row, or wraps below
|
// Remove Encryption button — trails the row, or wraps below
|
||||||
// when the column is too narrow for three buttons + status.
|
// when the column is too narrow for three buttons + status.
|
||||||
@@ -1704,7 +1711,11 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
||||||
bool isEncryptedPIN = app->state().isEncrypted();
|
bool isEncryptedPIN = app->state().isEncrypted();
|
||||||
if (isEncryptedPIN) {
|
// Lite has no PIN quick-unlock: its unlock paths (Settings, send-time prompt) take the passphrase
|
||||||
|
// directly and never consult a PIN vault, so these controls would be dead. Hide them.
|
||||||
|
if (app->isLiteBuild()) {
|
||||||
|
// nothing to show
|
||||||
|
} else if (isEncryptedPIN) {
|
||||||
bool hasPIN = app->hasPinVault();
|
bool hasPIN = app->hasPinVault();
|
||||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
||||||
|
|
||||||
@@ -1752,10 +1763,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
|
ImGui::TextColored(material::SuccessVec4(), ICON_MD_DIALPAD);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_pin_active"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2168,9 +2179,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
||||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = app->unlockLiteWallet(std::string(s_settingsState.lite_enc_pass), &unlockErr);
|
||||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||||
s_settingsState.lite_encryption_status = ok ? TR("lite_wallet_unlocked") : TR("lite_unlock_failed");
|
s_settingsState.lite_encryption_status = ok ? std::string(TR("lite_wallet_unlocked")) : unlockErr;
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
||||||
} else {
|
} else {
|
||||||
@@ -2327,8 +2339,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
||||||
cs.tooltip = TR("copy");
|
cs.tooltip = TR("copy");
|
||||||
const float ih = ImGui::GetFrameHeight();
|
const float ih = ImGui::GetFrameHeight();
|
||||||
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs))
|
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs)) {
|
||||||
ImGui::SetClipboardText(dirPath.c_str());
|
ImGui::SetClipboardText(dirPath.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
||||||
@@ -2591,6 +2605,21 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ui::DaemonUpdateDialog::consumeInstalled())
|
if (ui::DaemonUpdateDialog::consumeInstalled())
|
||||||
s_settingsState.daemon_info_loaded = false;
|
s_settingsState.daemon_info_loaded = false;
|
||||||
|
|
||||||
|
// Persistent reminder: a newer daemon binary is installed but the running node is still
|
||||||
|
// the old one until a restart (easy to miss on the updater's Done screen).
|
||||||
|
if (app->daemonRestartPending()) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + contentW);
|
||||||
|
Type().textColored(TypeStyle::Caption, material::Warning(), TR("daemon_restart_pending"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
if (app->isUsingEmbeddedDaemon()) {
|
||||||
|
if (material::ActionButton("##drestartpend", TR("daemon_update_restart_now"),
|
||||||
|
ICON_MD_RESTART_ALT, material::ActionTier::Primary))
|
||||||
|
app->restartDaemon();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_daemon_restart_pending"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- UPDATES ---
|
// --- UPDATES ---
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
|
||||||
@@ -2727,7 +2756,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::TextUnformatted(TR("transaction_url"));
|
ImGui::TextUnformatted(TR("transaction_url"));
|
||||||
ImGui::SameLine(urlRowX + urlLblW);
|
ImGui::SameLine(urlRowX + urlLblW);
|
||||||
ImGui::SetNextItemWidth(urlInputW);
|
ImGui::SetNextItemWidth(urlInputW);
|
||||||
|
// These Explorer-tab controls persist on edit — they were the only ones still relying on the
|
||||||
|
// About-only Save button, which a user on this tab never sees.
|
||||||
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
|
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
|
||||||
|
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
@@ -2737,6 +2769,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SameLine(urlRowX + urlLblW);
|
ImGui::SameLine(urlRowX + urlLblW);
|
||||||
ImGui::SetNextItemWidth(urlInputW);
|
ImGui::SetNextItemWidth(urlInputW);
|
||||||
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
|
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
|
||||||
|
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
|
||||||
|
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
@@ -2754,10 +2787,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
|
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
|
||||||
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
|
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
|
||||||
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
|
if (ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees))
|
||||||
|
saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
|
||||||
ImGui::SameLine(0, Layout::spacingLg());
|
ImGui::SameLine(0, Layout::spacingLg());
|
||||||
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
|
if (ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices))
|
||||||
|
saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
|
||||||
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
|
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
|
||||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
||||||
@@ -2962,7 +2997,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
||||||
saveSettingsPageState(app->settings());
|
saveSettingsPageState(app->settings());
|
||||||
if (s_settingsState.stratum_allowip[0] != '\0')
|
if (s_settingsState.stratum_allowip[0] != '\0')
|
||||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.25f, 1.0f), " %s",
|
ImGui::TextColored(material::WarningVec4(), " %s",
|
||||||
TR("stratum_expose_warn"));
|
TR("stratum_expose_warn"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2991,14 +3026,23 @@ void RenderSettingsPage(App* app) {
|
|||||||
s_settingsState.debug_gate_verifying = false;
|
s_settingsState.debug_gate_verifying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse")
|
||||||
|
: (s_settingsState.debug_gate_passed ? TR("tt_debug_expand") : TR("tt_debug_expand_gated")));
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
|
|
||||||
// Draw overline label + arrow on top of the invisible button
|
// Draw overline label + arrow on top of the invisible button
|
||||||
{
|
{
|
||||||
ImFont* ovFont = Type().overline();
|
ImFont* ovFont = Type().overline();
|
||||||
float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f;
|
float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f;
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(headerPos.x, textY), OnSurfaceMedium(), TR("debug_logging"));
|
float labelX = headerPos.x;
|
||||||
|
// Show a lock beside the label while the session gate hasn't been passed, so it's clear the
|
||||||
|
// section is gated rather than just a bare collapsible header.
|
||||||
|
if (!s_settingsState.debug_gate_passed) {
|
||||||
|
ImFont* lockFont = Type().iconSmall(); if (!lockFont) lockFont = ovFont;
|
||||||
|
dl->AddText(lockFont, lockFont->LegacySize, ImVec2(labelX, textY), OnSurfaceMedium(), ICON_MD_LOCK);
|
||||||
|
labelX += lockFont->CalcTextSizeA(lockFont->LegacySize, FLT_MAX, 0, ICON_MD_LOCK).x + Layout::spacingXs();
|
||||||
|
}
|
||||||
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, textY), OnSurfaceMedium(), TR("debug_logging"));
|
||||||
// Use the icon font for the expand/collapse arrow
|
// Use the icon font for the expand/collapse arrow
|
||||||
ImFont* iconFont = Type().iconSmall();
|
ImFont* iconFont = Type().iconSmall();
|
||||||
if (!iconFont) iconFont = ovFont;
|
if (!iconFont) iconFont = ovFont;
|
||||||
@@ -3006,6 +3050,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow);
|
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-line explanation under the collapsed header (what it is + that it's gated).
|
||||||
|
if (!s_settingsState.debug_expanded) {
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
|
||||||
|
s_settingsState.debug_gate_passed ? TR("debug_options_caption_unlocked")
|
||||||
|
: TR("debug_options_caption_gated"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
}
|
||||||
|
|
||||||
if (s_settingsState.debug_expanded) {
|
if (s_settingsState.debug_expanded) {
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
|
||||||
@@ -3049,9 +3101,33 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_debug_select"));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
ImGui::TextColored(ImVec4(1,1,1,0.35f), "%s", TR("settings_debug_restart_note"));
|
ImGui::TextUnformatted(TR("settings_debug_select"));
|
||||||
|
ImGui::TextWrapped("%s", TR("settings_debug_restart_note")); // was near-invisible 35% white
|
||||||
|
ImGui::PopStyleColor();
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
// Restart prompt moved directly under the note so the required action is prominent (appears the
|
||||||
|
// frame after a category toggles, which sets debug_cats_dirty while rendering the grid below).
|
||||||
|
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||||
|
ImFont* iconFont = Type().iconSmall();
|
||||||
|
if (iconFont) {
|
||||||
|
ImGui::PushFont(iconFont);
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(ICON_MD_INFO);
|
||||||
|
ImGui::PopFont();
|
||||||
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
|
}
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(TR("settings_debug_changed"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
|
s_settingsState.confirm_restart_daemon = true;
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
}
|
||||||
|
|
||||||
// The 22 dragonxd debug categories
|
// The 22 dragonxd debug categories
|
||||||
static const char* debugCats[] = {
|
static const char* debugCats[] = {
|
||||||
@@ -3109,27 +3185,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
// (The "restart daemon" prompt was moved up to directly under the restart note above.)
|
||||||
// "Restart daemon" button — only active when categories changed
|
|
||||||
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
|
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 218, 0, 255));
|
|
||||||
ImFont* iconFont = Type().iconSmall();
|
|
||||||
if (iconFont) {
|
|
||||||
ImGui::PushFont(iconFont);
|
|
||||||
ImGui::AlignTextToFramePadding();
|
|
||||||
ImGui::TextUnformatted(ICON_MD_INFO);
|
|
||||||
ImGui::PopFont();
|
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
|
||||||
}
|
|
||||||
ImGui::AlignTextToFramePadding();
|
|
||||||
ImGui::TextUnformatted(TR("settings_debug_changed"));
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
|
|
||||||
s_settingsState.confirm_restart_daemon = true;
|
|
||||||
}
|
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
|
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
|
||||||
@@ -3277,12 +3333,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirmation dialog for deleting blockchain data
|
// Confirmation dialog for deleting blockchain data
|
||||||
if (s_settingsState.confirm_delete_blockchain) {
|
if (s_settingsState.confirm_delete_blockchain) {
|
||||||
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::ErrorVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_delete_blockchain_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_delete_blockchain_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3324,7 +3380,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
@@ -3343,14 +3399,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
} else {
|
} else {
|
||||||
if (s_settingsState.rescan_probe_failed) {
|
if (s_settingsState.rescan_probe_failed) {
|
||||||
// A long line: TextColored does not wrap, so colour a wrapped one.
|
// A long line: TextColored does not wrap, so colour a wrapped one.
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::WarningVec4());
|
||||||
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_rescan_safe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3379,12 +3435,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
||||||
if (s_settingsState.confirm_repair_wallet) {
|
if (s_settingsState.confirm_repair_wallet) {
|
||||||
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_repair_wallet_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3407,12 +3463,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
||||||
if (s_settingsState.confirm_reinstall_daemon) {
|
if (s_settingsState.confirm_reinstall_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_reinstall_daemon_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3436,7 +3492,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
||||||
if (s_settingsState.confirm_restart_daemon) {
|
if (s_settingsState.confirm_restart_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
||||||
@@ -3463,12 +3519,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
||||||
if (s_settingsState.confirm_lite_redownload) {
|
if (s_settingsState.confirm_lite_redownload) {
|
||||||
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_lite_redownload_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_lite_redownload_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
#ifdef DRAGONX_DEBUG
|
#ifdef DRAGONX_DEBUG
|
||||||
ImGui::Text("%s", TR("about_build_type"));
|
ImGui::Text("%s", TR("about_build_type"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("about_debug"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("about_debug"));
|
||||||
#else
|
#else
|
||||||
ImGui::Text("%s", TR("about_build_type"));
|
ImGui::Text("%s", TR("about_build_type"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
@@ -92,7 +92,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
|
|
||||||
ImGui::Text("%s", TR("about_daemon"));
|
ImGui::Text("%s", TR("about_daemon"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("connected"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("connected"));
|
||||||
|
|
||||||
const auto& state = app->getWalletState();
|
const auto& state = app->getWalletState();
|
||||||
ImGui::Text("%s", TR("about_chain"));
|
ImGui::Text("%s", TR("about_chain"));
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ bool FormatTotalEarnings(App* app, std::string& outText, ImU32& outCol) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render compact hero line: logo + balance + USD + mining on one line
|
// Render compact hero line: logo + balance + USD + mining on one line
|
||||||
|
float DrawPendingChip(ImDrawList* dl, ImVec2 pos, float maxRight, ImFont* font, float fontSize) {
|
||||||
|
using namespace material;
|
||||||
|
if (s_dispUnconfirmed <= 1e-9) return 0.0f;
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
|
||||||
|
const float w = font->CalcTextSizeA(fontSize, 10000.0f, 0.0f, buf).x;
|
||||||
|
if (pos.x + w > maxRight) return 0.0f; // would overflow the card — skip rather than overlap
|
||||||
|
dl->AddText(font, fontSize, pos, WithAlpha(Warning(), 230), buf);
|
||||||
|
if (IsRectHovered(pos, ImVec2(pos.x + w, pos.y + fontSize))) Tooltip("%s", TR("baltab_pending_tip"));
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
|
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
|
||||||
using namespace material;
|
using namespace material;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
@@ -136,6 +148,18 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
|
|||||||
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
|
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
|
||||||
|
// Pending (unconfirmed) chip — so these layouts show it too, labeled, not just the Classic card.
|
||||||
|
if (s_dispUnconfirmed > 1e-9) {
|
||||||
|
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
|
||||||
|
ImVec2 pp = ImGui::GetCursorScreenPos();
|
||||||
|
dl->AddText(capFont, tickerFontSize, ImVec2(pp.x, tickerY), WithAlpha(Warning(), 230), buf);
|
||||||
|
float pw = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, buf).x;
|
||||||
|
if (material::IsRectHovered(ImVec2(pp.x, tickerY), ImVec2(pp.x + pw, tickerY + tickerFontSize)))
|
||||||
|
material::Tooltip("%s", TR("baltab_pending_tip"));
|
||||||
|
ImGui::Dummy(ImVec2(pw + Layout::spacingLg(), lineH));
|
||||||
|
ImGui::SameLine();
|
||||||
|
}
|
||||||
|
|
||||||
// Total period-earnings (left of the USD) when the global earnings toggle is on.
|
// Total period-earnings (left of the USD) when the global earnings toggle is on.
|
||||||
{
|
{
|
||||||
std::string earnText; ImU32 earnCol;
|
std::string earnText; ImU32 earnCol;
|
||||||
@@ -1033,7 +1057,27 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
||||||
|
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
|
const bool roomForIcon = ImGui::GetContentRegionAvail().y >= 100.0f * dp;
|
||||||
|
if (!app->isConnected()) {
|
||||||
|
// Disconnected: not "empty" and not "loading" — keep a plain caption.
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||||
|
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
|
||||||
|
} else {
|
||||||
|
const std::string txLoadingText = app->transactionRefreshProgressText();
|
||||||
|
// In lite, transactionRefreshProgressText() is empty and last_tx_update is stamped differently,
|
||||||
|
// so treat only an active sync as "loading" there.
|
||||||
|
const bool loadingOrSyncing = !txLoadingText.empty() ||
|
||||||
|
(state.sync.syncing && !state.sync.isSynced()) ||
|
||||||
|
(!app->isLiteBuild() && state.last_tx_update == 0);
|
||||||
|
if (loadingOrSyncing) {
|
||||||
|
char lbuf[192];
|
||||||
|
const char* base = !txLoadingText.empty() ? txLoadingText.c_str() : TR("loading_transactions");
|
||||||
|
snprintf(lbuf, sizeof(lbuf), "%s%s", base, material::LoadingDots());
|
||||||
|
material::DrawEmptyState(roomForIcon ? ICON_MD_HOURGLASS_EMPTY : nullptr, lbuf);
|
||||||
|
} else {
|
||||||
|
material::DrawEmptyState(roomForIcon ? ICON_MD_RECEIPT_LONG : nullptr, TR("no_transactions_yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
ImFont* capFont = Type().caption();
|
ImFont* capFont = Type().caption();
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ void RenderSharedAddressList(App* app, float listH, float availW, float glassRou
|
|||||||
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
||||||
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
||||||
|
|
||||||
|
// Draws a labeled "Pending +X" chip (unconfirmed balance) at `pos`, via the draw list only, when there
|
||||||
|
// is unconfirmed balance AND it fits left of `maxRight`. Returns the width drawn (0 if skipped). Adds a
|
||||||
|
// hover tooltip. Shared by the balance layouts so pending isn't visible in only one of them.
|
||||||
|
float DrawPendingChip(ImDrawList* dl, ImVec2 pos, float maxRight, ImFont* font, float fontSize);
|
||||||
|
|
||||||
// Total period-earnings for the whole wallet, formatted per the earnings settings. Returns false
|
// Total period-earnings for the whole wallet, formatted per the earnings settings. Returns false
|
||||||
// (and leaves out* untouched) when the global earnings toggle is off, so layouts show it only when
|
// (and leaves out* untouched) when the global earnings toggle is off, so layouts show it only when
|
||||||
// the user has opted into the earnings view. Shared by RenderCompactHero and the layout totals.
|
// the user has opted into the earnings view. Shared by RenderCompactHero and the layout totals.
|
||||||
|
|||||||
@@ -525,9 +525,10 @@ static void RenderBalanceClassic(App* app)
|
|||||||
WithAlpha(Success(), 160), buf);
|
WithAlpha(Success(), 160), buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unconfirmed badge (top-right corner)
|
// Pending badge (top-right corner) — labeled so an unconfirmed "+X" doesn't read as a mystery.
|
||||||
|
bool pendingChipHovered = false;
|
||||||
if (state.unconfirmed_balance > 0.0) {
|
if (state.unconfirmed_balance > 0.0) {
|
||||||
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
|
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), state.unconfirmed_balance);
|
||||||
ImVec2 ts = capFont->CalcTextSizeA(
|
ImVec2 ts = capFont->CalcTextSizeA(
|
||||||
capFont->LegacySize, 10000, 0, buf);
|
capFont->LegacySize, 10000, 0, buf);
|
||||||
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
|
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
|
||||||
@@ -540,6 +541,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
|
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
|
||||||
amberCol, buf);
|
amberCol, buf);
|
||||||
|
if (material::IsRectHovered(bMin, bMax)) { pendingChipHovered = true; material::Tooltip("%s", TR("baltab_pending_tip")); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover glow + click to Receive
|
// Hover glow + click to Receive
|
||||||
@@ -547,6 +549,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
if (!pendingChipHovered) material::Tooltip("%s", TR("shielded_card_tooltip"));
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(NavPage::Receive);
|
app->setCurrentPage(NavPage::Receive);
|
||||||
}
|
}
|
||||||
@@ -747,6 +750,11 @@ static void RenderBalanceDonut(App* app) {
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
|
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
|
||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
|
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
|
||||||
|
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
|
||||||
|
+ Layout::spacingMd(), tickerY),
|
||||||
|
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
|
||||||
|
capFont, capFont->LegacySize);
|
||||||
ImGui::NewLine();
|
ImGui::NewLine();
|
||||||
|
|
||||||
// USD value
|
// USD value
|
||||||
@@ -1662,6 +1670,11 @@ static void RenderBalanceShield(App* app) {
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
|
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
|
||||||
|
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
|
||||||
|
+ Layout::spacingMd(), pos.y + heroSz.y - capFont->LegacySize),
|
||||||
|
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
|
||||||
|
capFont, capFont->LegacySize);
|
||||||
ImGui::NewLine();
|
ImGui::NewLine();
|
||||||
|
|
||||||
double usd_value = state.getBalanceUSD();
|
double usd_value = state.getBalanceUSD();
|
||||||
@@ -1851,6 +1864,11 @@ static void RenderBalanceTimeline(App* app) {
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
|
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
|
||||||
|
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
|
||||||
|
+ Layout::spacingMd(), pos.y + heroSz.y - capFont->LegacySize),
|
||||||
|
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
|
||||||
|
capFont, capFont->LegacySize);
|
||||||
ImGui::NewLine();
|
ImGui::NewLine();
|
||||||
|
|
||||||
double usd_value = state.getBalanceUSD();
|
double usd_value = state.getBalanceUSD();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public:
|
|||||||
s_state = State::Confirm;
|
s_state = State::Confirm;
|
||||||
s_bootstrap.reset();
|
s_bootstrap.reset();
|
||||||
s_errorMsg.clear();
|
s_errorMsg.clear();
|
||||||
|
s_lastUrl.clear();
|
||||||
s_wasDaemonRunning = false;
|
s_wasDaemonRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,13 +283,17 @@ private:
|
|||||||
|
|
||||||
Type().textColored(TypeStyle::H6, Success(), TR("bootstrap_success"));
|
Type().textColored(TypeStyle::H6, Success(), TR("bootstrap_success"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("bootstrap_success_desc"));
|
// For the embedded daemon, tell the user the node will start (and offer the button) instead of
|
||||||
|
// instructing them to start it by hand. External-daemon users keep the manual-start wording.
|
||||||
|
const bool embedded = s_app->isUsingEmbeddedDaemon();
|
||||||
|
ImGui::TextWrapped("%s", TR(embedded ? "bootstrap_success_desc_start" : "bootstrap_success_desc"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
float btnW = 140.0f * dp;
|
float btnW = 150.0f * dp;
|
||||||
if (s_wasDaemonRunning) {
|
if (s_wasDaemonRunning || embedded) {
|
||||||
if (TactileButton(TR("bootstrap_restart_daemon"), ImVec2(btnW, 0))) {
|
if (TactileButton(s_wasDaemonRunning ? TR("bootstrap_restart_daemon") : TR("bootstrap_start_daemon"),
|
||||||
|
ImVec2(btnW, 0))) {
|
||||||
s_app->setBootstrapDownloading(false);
|
s_app->setBootstrapDownloading(false);
|
||||||
s_app->startEmbeddedDaemon();
|
s_app->startEmbeddedDaemon();
|
||||||
s_open = false;
|
s_open = false;
|
||||||
@@ -314,9 +319,15 @@ private:
|
|||||||
|
|
||||||
float btnW = 120.0f * dp;
|
float btnW = 120.0f * dp;
|
||||||
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
|
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
|
||||||
startDownload("");
|
startDownload(s_lastUrl); // keep the user's source/mirror choice (was startDownload(""))
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
{
|
||||||
|
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||||
|
if (s_lastUrl != mirrorUrl && TactileButton(TR("bootstrap_try_mirror"), ImVec2(130.0f * dp, 0)))
|
||||||
|
startDownload(mirrorUrl);
|
||||||
|
ImGui::SameLine();
|
||||||
|
}
|
||||||
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
|
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
|
||||||
s_app->setBootstrapDownloading(false);
|
s_app->setBootstrapDownloading(false);
|
||||||
s_open = false;
|
s_open = false;
|
||||||
@@ -336,6 +347,7 @@ private:
|
|||||||
s_bootstrap->start(dataDir);
|
s_bootstrap->start(dataDir);
|
||||||
else
|
else
|
||||||
s_bootstrap->start(dataDir, url);
|
s_bootstrap->start(dataDir, url);
|
||||||
|
s_lastUrl = url; // remember what we started (set only after the guards passed)
|
||||||
s_state = State::Downloading;
|
s_state = State::Downloading;
|
||||||
s_errorMsg.clear();
|
s_errorMsg.clear();
|
||||||
s_cancelling = false;
|
s_cancelling = false;
|
||||||
@@ -347,6 +359,7 @@ private:
|
|||||||
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
|
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
|
||||||
static inline bool s_wasDaemonRunning = false;
|
static inline bool s_wasDaemonRunning = false;
|
||||||
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
|
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
|
||||||
|
static inline std::string s_lastUrl; // last source started ("" = default); so Retry keeps the mirror choice
|
||||||
static inline std::string s_errorMsg;
|
static inline std::string s_errorMsg;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+242
-34
@@ -104,6 +104,18 @@ bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (
|
|||||||
std::string s_delete_cid; // conversation targeted by the delete confirm
|
std::string s_delete_cid; // conversation targeted by the delete confirm
|
||||||
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
||||||
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
||||||
|
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
|
||||||
|
std::string s_export_cid; // conversation targeted by the export confirm
|
||||||
|
bool s_show_fingerprint = false; // key-fingerprint verification dialog
|
||||||
|
std::string s_fingerprint_cid; // conversation targeted by the fingerprint dialog
|
||||||
|
|
||||||
|
// Export file path for a conversation (the same scheme the inline export used).
|
||||||
|
static std::string chatExportPathFor(const std::string& peerName) {
|
||||||
|
std::string safe;
|
||||||
|
for (char ch : peerName) safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
|
||||||
|
if (safe.empty()) safe = "chat";
|
||||||
|
return util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
|
||||||
|
}
|
||||||
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
|
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
|
||||||
char s_emoji_search[48] = ""; // emoji picker keyword filter
|
char s_emoji_search[48] = ""; // emoji picker keyword filter
|
||||||
|
|
||||||
@@ -537,6 +549,7 @@ struct ConvSummary {
|
|||||||
std::int64_t lastTs = 0;
|
std::int64_t lastTs = 0;
|
||||||
int count = 0;
|
int count = 0;
|
||||||
bool hidden = false; // shown only while "Show hidden" is on
|
bool hidden = false; // shown only while "Show hidden" is on
|
||||||
|
bool pendingRequest = false; // unsolicited incoming contact request from a stranger: awaiting Accept/Block
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
|
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
|
||||||
@@ -578,6 +591,17 @@ void centeredHint(const char* text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
|
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
|
||||||
|
// Copy the user's own chat address to the clipboard (shared by the sidebar button and the intro card).
|
||||||
|
void copyMyChatAddress(App* app) {
|
||||||
|
std::string myAddr = app->chatMyAddress();
|
||||||
|
if (myAddr.empty()) {
|
||||||
|
Notifications::instance().warning(TR("chat_no_my_address"));
|
||||||
|
} else {
|
||||||
|
ImGui::SetClipboardText(myAddr.c_str());
|
||||||
|
Notifications::instance().info(TR("address_copied"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
||||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||||
const ImVec2 origin = ImGui::GetCursorPos();
|
const ImVec2 origin = ImGui::GetCursorPos();
|
||||||
@@ -808,9 +832,11 @@ void RenderChatTab(App* app)
|
|||||||
c.cid = cid;
|
c.cid = cid;
|
||||||
c.hidden = hidden;
|
c.hidden = hidden;
|
||||||
c.count = static_cast<int>(messages.size());
|
c.count = static_cast<int>(messages.size());
|
||||||
|
bool sentAny = false;
|
||||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||||
|
if (m.direction == chat::ChatDirection::Outgoing) sentAny = true;
|
||||||
}
|
}
|
||||||
const auto& last = messages.back();
|
const auto& last = messages.back();
|
||||||
c.lastBody = last.body;
|
c.lastBody = last.body;
|
||||||
@@ -818,6 +844,12 @@ void RenderChatTab(App* app)
|
|||||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||||
|
// Unsolicited: first message is an incoming contact request, we've never replied, and the peer
|
||||||
|
// isn't a saved contact → show an Accept/Block card instead of treating it as a normal chat.
|
||||||
|
const auto& first = messages.front();
|
||||||
|
c.pendingRequest = (idx < 0) && !sentAny && !c.peerZaddr.empty() &&
|
||||||
|
first.direction == chat::ChatDirection::Incoming &&
|
||||||
|
first.kind == chat::ChatMessageKind::ContactRequest;
|
||||||
s_convs.push_back(std::move(c));
|
s_convs.push_back(std::move(c));
|
||||||
}
|
}
|
||||||
std::sort(s_convs.begin(), s_convs.end(),
|
std::sort(s_convs.begin(), s_convs.end(),
|
||||||
@@ -935,15 +967,7 @@ void RenderChatTab(App* app)
|
|||||||
{
|
{
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
|
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
|
||||||
if (ImGui::SmallButton(TR("chat_copy_my_address"))) {
|
if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
|
||||||
std::string myAddr = app->chatMyAddress();
|
|
||||||
if (myAddr.empty()) {
|
|
||||||
Notifications::instance().warning(TR("chat_no_my_address"));
|
|
||||||
} else {
|
|
||||||
ImGui::SetClipboardText(myAddr.c_str());
|
|
||||||
Notifications::instance().info(TR("address_copied"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
|
||||||
}
|
}
|
||||||
@@ -1013,9 +1037,21 @@ void RenderChatTab(App* app)
|
|||||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
||||||
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
||||||
dl->PopClipRect();
|
dl->PopClipRect();
|
||||||
|
// Pending-request pill (bottom-right) for an unsolicited stranger; the preview is clipped to its left.
|
||||||
|
float previewRight = mx.x - pad;
|
||||||
|
if (c.pendingRequest) {
|
||||||
|
const char* rl = TR("chat_request_pill");
|
||||||
|
const ImVec2 rs = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, rl);
|
||||||
|
const float px = 6.0f * dp, py = 1.0f * dp;
|
||||||
|
const ImVec2 pmax(mx.x - pad, p.y + rowH - pad + py);
|
||||||
|
const ImVec2 pmin(pmax.x - rs.x - 2.0f * px, pmax.y - rs.y - 2.0f * py);
|
||||||
|
dl->AddRectFilled(pmin, pmax, material::WithAlpha(material::Warning(), 60), (rs.y + 2.0f * py) * 0.5f);
|
||||||
|
dl->AddText(metaFont, metaSz, ImVec2(pmin.x + px, pmin.y + py), material::Warning(), rl);
|
||||||
|
previewRight = pmin.x - pad;
|
||||||
|
}
|
||||||
// Preview (bottom, clipped to the text column, muted).
|
// Preview (bottom, clipped to the text column, muted).
|
||||||
const std::string preview = previewOf(c.lastBody);
|
const std::string preview = previewOf(c.lastBody);
|
||||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
|
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, previewRight), mx.y), true);
|
||||||
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
|
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
|
||||||
material::OnSurfaceMedium(), preview.c_str());
|
material::OnSurfaceMedium(), preview.c_str());
|
||||||
dl->PopClipRect();
|
dl->PopClipRect();
|
||||||
@@ -1220,23 +1256,9 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
||||||
material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
|
material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
|
||||||
if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
|
if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
|
||||||
std::string content = "DragonX chat export\n";
|
// The export writes DECRYPTED plaintext — confirm first instead of writing on one click.
|
||||||
content += sel->peerName + " <" + sel->peerZaddr + ">\n";
|
s_export_cid = sel->cid;
|
||||||
content += "cid: " + sel->cid + "\n\n";
|
s_show_export_confirm = true;
|
||||||
for (const auto& m : store.conversation(sel->cid)) {
|
|
||||||
const bool out = (m.direction == chat::ChatDirection::Outgoing);
|
|
||||||
content += "[" + formatTime(m.timestamp) + "] " +
|
|
||||||
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
|
|
||||||
}
|
|
||||||
std::string safe;
|
|
||||||
for (char ch : sel->peerName)
|
|
||||||
safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
|
|
||||||
if (safe.empty()) safe = "chat";
|
|
||||||
const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
|
|
||||||
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
|
|
||||||
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
|
|
||||||
else
|
|
||||||
Notifications::instance().error(TR("chat_export_failed"));
|
|
||||||
}
|
}
|
||||||
bx += ib + gap;
|
bx += ib + gap;
|
||||||
}
|
}
|
||||||
@@ -1307,13 +1329,22 @@ void RenderChatTab(App* app)
|
|||||||
// same row. Hidden while renaming (the field takes the whole span).
|
// same row. Hidden while renaming (the field takes the whole span).
|
||||||
if (hasAddr && !renaming) {
|
if (hasAddr && !renaming) {
|
||||||
float ax = textX + std::min(nameW, nameAvail) + nameSpacing;
|
float ax = textX + std::min(nameW, nameAvail) + nameSpacing;
|
||||||
// Key-verify lock — we hold the peer's identity key; hover shows a comparable fingerprint.
|
// Key-verify lock — we hold the peer's identity key; now clickable to open a dialog with the
|
||||||
|
// fingerprint for out-of-band comparison (the old hover-only lock was unexplained).
|
||||||
if (showLock) {
|
if (showLock) {
|
||||||
const float ly = hp.y + (rowH - icoSz) * 0.5f;
|
const float ly = hp.y + (rowH - icoSz) * 0.5f;
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(ax, ly));
|
||||||
|
const bool lockClicked = ImGui::InvisibleButton("##hdr_keylock",
|
||||||
|
ImVec2(std::max(1.0f, lockW - 5.0f * hdpi), icoSz));
|
||||||
|
const bool lockHov = ImGui::IsItemHovered();
|
||||||
hdl->AddText(icoFont, icoSz, ImVec2(ax, ly),
|
hdl->AddText(icoFont, icoSz, ImVec2(ax, ly),
|
||||||
material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK);
|
lockHov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
|
||||||
if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz)))
|
ICON_MD_LOCK);
|
||||||
material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str());
|
if (lockHov) {
|
||||||
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
material::Tooltip("%s", TR("chat_verify_tip"));
|
||||||
|
}
|
||||||
|
if (lockClicked) { s_fingerprint_cid = sel->cid; s_show_fingerprint = true; }
|
||||||
ax += lockW;
|
ax += lockW;
|
||||||
}
|
}
|
||||||
// Click-to-copy compact address.
|
// Click-to-copy compact address.
|
||||||
@@ -1340,6 +1371,42 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
|
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
|
// Stranger contact-request card: Accept (add to contacts) or Block (delete + block). Normal
|
||||||
|
// ImGui flow, so ##ChatMessages below (sized from the remaining content height) shrinks to fit.
|
||||||
|
if (sel->pendingRequest) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
|
||||||
|
ImGui::TextUnformatted(TR("chat_request_title"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_request_body"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
if (ImGui::SmallButton(TR("chat_request_accept"))) {
|
||||||
|
data::AddressBookEntry e;
|
||||||
|
e.address = sel->peerZaddr;
|
||||||
|
e.label = sel->peerName;
|
||||||
|
book.addEntry(e); // book.revision() bumps → the request flag clears on the next rebuild
|
||||||
|
Notifications::instance().info(TR("chat_request_accepted"));
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::SmallButton(TR("chat_request_block"))) {
|
||||||
|
const std::string cid = sel->cid, nm = sel->peerName; // copy before deleteConversation
|
||||||
|
if (app->chatService().deleteConversation(cid, /*block=*/true)) {
|
||||||
|
if (app->settings()) {
|
||||||
|
app->settings()->setChatBlocked(cid, nm, true);
|
||||||
|
app->settings()->setChatHidden(cid, false);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
if (s_selected_cid == cid) s_selected_cid.clear();
|
||||||
|
Notifications::instance().info(TR("chat_blocked_toast"));
|
||||||
|
} else {
|
||||||
|
Notifications::instance().error(TR("chat_delete_failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::Separator();
|
||||||
|
}
|
||||||
|
|
||||||
const float dp = Layout::dpiScale();
|
const float dp = Layout::dpiScale();
|
||||||
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
|
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
|
||||||
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
|
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
|
||||||
@@ -1671,7 +1738,10 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ImGui::BeginPopup("##msgmenu")) {
|
if (ImGui::BeginPopup("##msgmenu")) {
|
||||||
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
|
if (ImGui::MenuItem(TR("copy"))) {
|
||||||
|
ImGui::SetClipboardText(m.body.c_str());
|
||||||
|
Notifications::instance().success(TR("copied"));
|
||||||
|
}
|
||||||
ImGui::EndPopup();
|
ImGui::EndPopup();
|
||||||
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
||||||
s_msgmenu_index = -1; // popup closed
|
s_msgmenu_index = -1; // popup closed
|
||||||
@@ -1811,9 +1881,35 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
|
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
|
||||||
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
|
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
|
||||||
|
} else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) {
|
||||||
|
// First-run intro: explain how chat works + let the user copy their address, instead of a bare
|
||||||
|
// "no conversations yet" line. Dismissal is persisted.
|
||||||
|
const float idp = Layout::dpiScale();
|
||||||
|
const float availX = ImGui::GetContentRegionAvail().x;
|
||||||
|
const float cardW = std::min(availX - 40.0f * idp, 460.0f * idp);
|
||||||
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - cardW) * 0.5f));
|
||||||
|
ImGui::BeginGroup();
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + cardW);
|
||||||
|
material::Type().textColored(material::TypeStyle::Subtitle1, material::OnSurface(), TR("chat_intro_title"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_intro_body"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_intro_fee"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
if (material::TactileButton(TR("chat_copy_my_address"), ImVec2(cardW, 0))) copyMyChatAddress(app);
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (material::TactileButton(TR("chat_intro_dismiss"), ImVec2(cardW, 0))) {
|
||||||
|
app->settings()->setChatIntroDismissed(true);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
ImGui::EndGroup();
|
||||||
|
} else if (convs.empty()) {
|
||||||
|
centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
|
||||||
} else {
|
} else {
|
||||||
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
|
centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
||||||
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
@@ -2094,6 +2190,113 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Export-as-plaintext confirm (unencrypted file on disk) ----
|
||||||
|
if (s_show_export_confirm) {
|
||||||
|
const ConvSummary* ex = nullptr;
|
||||||
|
for (const auto& c : convs) if (c.cid == s_export_cid) { ex = &c; break; }
|
||||||
|
if (!ex) {
|
||||||
|
s_show_export_confirm = false;
|
||||||
|
s_export_cid.clear();
|
||||||
|
} else {
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("chat_export_confirm_title");
|
||||||
|
ov.p_open = &s_show_export_confirm;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 500.0f;
|
||||||
|
ov.idSuffix = "chatexport";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
const float edp = Layout::dpiScale();
|
||||||
|
const std::string path = chatExportPathFor(ex->peerName);
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_export_confirm_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||||
|
ImGui::TextUnformatted(TR("chat_export_confirm_path"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::TextWrapped("%s", path.c_str());
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
|
||||||
|
const float egap = ImGui::GetStyle().ItemSpacing.x;
|
||||||
|
float wExp = std::max(110.0f * edp, ImGui::CalcTextSize(TR("chat_export_confirm_btn")).x + 24.0f * edp);
|
||||||
|
float wCan = std::max(90.0f * edp, ImGui::CalcTextSize(TR("chat_cancel")).x + 24.0f * edp);
|
||||||
|
material::BeginOverlayDialogFooter(wExp + wCan + egap, false);
|
||||||
|
if (material::TactileButton(TR("chat_export_confirm_btn"), ImVec2(wExp, 0))) {
|
||||||
|
// Build + write exactly as the old inline export did (format byte-identical).
|
||||||
|
std::string content = "DragonX chat export\n";
|
||||||
|
content += ex->peerName + " <" + ex->peerZaddr + ">\n";
|
||||||
|
content += "cid: " + ex->cid + "\n\n";
|
||||||
|
for (const auto& m : store.conversation(ex->cid)) {
|
||||||
|
const bool out = (m.direction == chat::ChatDirection::Outgoing);
|
||||||
|
content += "[" + formatTime(m.timestamp) + "] " +
|
||||||
|
(out ? std::string(TR("chat_you")) : ex->peerName) + ": " + m.body + "\n";
|
||||||
|
}
|
||||||
|
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
|
||||||
|
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
|
||||||
|
else
|
||||||
|
Notifications::instance().error(TR("chat_export_failed"));
|
||||||
|
s_show_export_confirm = false;
|
||||||
|
s_export_cid.clear();
|
||||||
|
}
|
||||||
|
ImGui::SameLine(0, egap);
|
||||||
|
if (material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0))) {
|
||||||
|
s_show_export_confirm = false;
|
||||||
|
s_export_cid.clear();
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Key-fingerprint verification dialog ----
|
||||||
|
if (s_show_fingerprint) {
|
||||||
|
const ConvSummary* fp = nullptr;
|
||||||
|
for (const auto& c : convs) if (c.cid == s_fingerprint_cid) { fp = &c; break; }
|
||||||
|
if (!fp || fp->peerPubKey.empty()) {
|
||||||
|
s_show_fingerprint = false;
|
||||||
|
s_fingerprint_cid.clear();
|
||||||
|
} else {
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("chat_verify_title");
|
||||||
|
ov.p_open = &s_show_fingerprint;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 500.0f;
|
||||||
|
ov.idSuffix = "chatfp";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
const float fdp = Layout::dpiScale();
|
||||||
|
const std::string fpStr = keyFingerprint(fp->peerPubKey);
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_verify_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextWrapped("%s", fp->peerName.c_str());
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||||
|
ImGui::TextWrapped("%s", fpStr.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
|
||||||
|
const float fgap = ImGui::GetStyle().ItemSpacing.x;
|
||||||
|
float wCopy = std::max(110.0f * fdp, ImGui::CalcTextSize(TR("chat_verify_copy")).x + 24.0f * fdp);
|
||||||
|
float wClose = std::max(90.0f * fdp, ImGui::CalcTextSize(TR("chat_cancel")).x + 24.0f * fdp);
|
||||||
|
material::BeginOverlayDialogFooter(wCopy + wClose + fgap, false);
|
||||||
|
if (material::TactileButton(TR("chat_verify_copy"), ImVec2(wCopy, 0))) {
|
||||||
|
ImGui::SetClipboardText(fp->peerPubKey.c_str());
|
||||||
|
Notifications::instance().success(TR("copied"));
|
||||||
|
}
|
||||||
|
ImGui::SameLine(0, fgap);
|
||||||
|
if (material::TactileButton(TR("chat_cancel"), ImVec2(wClose, 0))) {
|
||||||
|
s_show_fingerprint = false;
|
||||||
|
s_fingerprint_cid.clear();
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
|
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
|
||||||
if (s_show_delete_confirm) {
|
if (s_show_delete_confirm) {
|
||||||
const float dp = Layout::dpiScale();
|
const float dp = Layout::dpiScale();
|
||||||
@@ -2281,11 +2484,16 @@ void ResetChatTab()
|
|||||||
s_show_blocked = false;
|
s_show_blocked = false;
|
||||||
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
||||||
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
||||||
|
// Zero the decrypted plaintext the memo caches hold (conversation previews + full thread bodies)
|
||||||
|
// before dropping them, so the previous wallet's messages don't linger in freed heap.
|
||||||
|
auto wipeStr2 = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
for (auto& c : s_convs) wipeStr2(c.lastBody);
|
||||||
s_convs.clear();
|
s_convs.clear();
|
||||||
s_convsHidden = 0;
|
s_convsHidden = 0;
|
||||||
s_convsKey = ~0ull;
|
s_convsKey = ~0ull;
|
||||||
s_threadCid.clear();
|
s_threadCid.clear();
|
||||||
s_threadRev = ~0ull;
|
s_threadRev = ~0ull;
|
||||||
|
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
|
||||||
s_threadMsgs.clear();
|
s_threadMsgs.clear();
|
||||||
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
|
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
|
||||||
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
|
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// tab completion, daemon log display, and color-coded output.
|
// tab completion, daemon log display, and color-coded output.
|
||||||
|
|
||||||
#include "console_tab.h"
|
#include "console_tab.h"
|
||||||
|
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
|
||||||
#include "console_command_executor.h"
|
#include "console_command_executor.h"
|
||||||
#include "console_command_reference.h"
|
#include "console_command_reference.h"
|
||||||
#include "console_input_model.h"
|
#include "console_input_model.h"
|
||||||
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
|||||||
{
|
{
|
||||||
std::string result;
|
std::string result;
|
||||||
bool isError = false;
|
bool isError = false;
|
||||||
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
|
while (exec.pollResult(result, isError)) {
|
||||||
|
const bool secret = secret_result_armed_ && !isError;
|
||||||
|
secret_result_armed_ = false; // the first (non-error) result consumes the arming
|
||||||
|
if (secret) {
|
||||||
|
// Hold the plaintext ONLY in secret_result_ and show a masked placeholder — it never
|
||||||
|
// enters the scrollback model, so Copy All / Ctrl+C / the filter can't leak it.
|
||||||
|
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||||
|
secret_result_ = result;
|
||||||
|
secret_result_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(60);
|
||||||
|
addLine(TR("console_secret_result_hidden"), ConsoleChannel::Warning);
|
||||||
|
} else {
|
||||||
|
addFormattedResult(result, isError);
|
||||||
|
}
|
||||||
|
if (!result.empty()) sodium_memzero(&result[0], result.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-wipe a revealed secret result after its deadline so it doesn't linger for the session.
|
||||||
|
if (!secret_result_.empty() && std::chrono::steady_clock::now() >= secret_result_deadline_) {
|
||||||
|
wipeSecretResult();
|
||||||
|
addLine(TR("console_secret_result_expired"), ConsoleChannel::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
||||||
@@ -548,6 +568,20 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
|||||||
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a key-revealing result is masked, offer an explicit Copy-secret (through the auto-clearing
|
||||||
|
// clipboard) and a Hide action. The secret is never in the model, so the normal Copy above is safe.
|
||||||
|
if (hasSecretResult()) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("console_copy_secret"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
|
||||||
|
if (copy_secret_cb_) copy_secret_cb_(secret_result_);
|
||||||
|
else ImGui::SetClipboardText(secret_result_.c_str());
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_copy_secret_tip"));
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("console_hide_secret"), ImVec2(0, 0), schema::UI().resolveFont("button")))
|
||||||
|
wipeSecretResult();
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
|
||||||
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
||||||
@@ -1552,6 +1586,12 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
|||||||
if (n == firstLower) return cat.commands[i].destructive;
|
if (n == firstLower) return cat.commands[i].destructive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Secret-revealing full-node commands that aren't in the reference catalog still deserve the gate
|
||||||
|
// (e.g. z_exportmnemonic dumps the seed phrase; the lite "seed" equivalent is already flagged).
|
||||||
|
if (fullNode) {
|
||||||
|
static const char* kSecretReveal[] = { "z_exportmnemonic", "z_exportviewingkey" };
|
||||||
|
for (const char* s : kSecretReveal) if (firstLower == s) return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -1559,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
|||||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||||
{
|
{
|
||||||
if (cmd.empty()) return false;
|
if (cmd.empty()) return false;
|
||||||
|
secret_result_armed_ = false; // cleared each submit so a no-result command can't mask the next one
|
||||||
|
|
||||||
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||||
// log and the recall history. The real `cmd` below is still executed unredacted.
|
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||||
@@ -1616,6 +1657,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
|
|||||||
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
||||||
} else {
|
} else {
|
||||||
destructive_confirm_cmd_.clear();
|
destructive_confirm_cmd_.clear();
|
||||||
|
// Arm result-masking for key-revealing commands so their OUTPUT is hidden in the scrollback.
|
||||||
|
secret_result_armed_ = ConsoleCommandRevealsSecret(cmd, exec.hasRpcReference());
|
||||||
exec.submit(cmd);
|
exec.submit(cmd);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1882,6 +1925,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
|
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
|
||||||
pending_submit_ = built;
|
pending_submit_ = built;
|
||||||
|
// The reference dialog already confirmed this run, so pre-arm the destructive gate to an exact
|
||||||
|
// match — otherwise the replayed submit re-arms it and silently does nothing until retyped.
|
||||||
|
destructive_confirm_cmd_ = built;
|
||||||
command_search_[0] = '\0';
|
command_search_[0] = '\0';
|
||||||
run_confirm_cmd_ = nullptr;
|
run_confirm_cmd_ = nullptr;
|
||||||
show_commands_popup_ = false;
|
show_commands_popup_ = false;
|
||||||
@@ -2193,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
|
|||||||
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConsoleTab::wipeSecretResult()
|
||||||
|
{
|
||||||
|
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||||
|
secret_result_.clear();
|
||||||
|
secret_result_armed_ = false;
|
||||||
|
secret_result_deadline_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
void ConsoleTab::clear()
|
void ConsoleTab::clear()
|
||||||
{
|
{
|
||||||
|
wipeSecretResult(); // the clear command / toolbar button also drops any held secret result
|
||||||
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
||||||
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||||
model_.clear();
|
model_.clear();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <functional>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -66,6 +68,13 @@ public:
|
|||||||
*/
|
*/
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
|
// Masked secret-result support: the plaintext of a key-revealing command's output is held ONLY here
|
||||||
|
// (never in the scrollback model), so Copy All / Ctrl+C / the filter can't leak it. An explicit
|
||||||
|
// "Copy secret" action routes through this handler (the app wires it to the auto-clearing clipboard).
|
||||||
|
void setCopySecretHandler(std::function<void(const std::string&)> f) { copy_secret_cb_ = std::move(f); }
|
||||||
|
bool hasSecretResult() const { return !secret_result_.empty(); }
|
||||||
|
void wipeSecretResult(); // zero + drop the buffer, disarm, reset the deadline (call on lock/clear)
|
||||||
|
|
||||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||||
void requestInputFocus() { focus_input_pending_ = true; }
|
void requestInputFocus() { focus_input_pending_ = true; }
|
||||||
@@ -162,6 +171,10 @@ private:
|
|||||||
char input_buffer_[4096] = {0};
|
char input_buffer_[4096] = {0};
|
||||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||||
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
||||||
|
bool secret_result_armed_ = false; // the just-submitted command reveals key material in its result
|
||||||
|
std::string secret_result_; // plaintext of the masked result (member-only; never in model_)
|
||||||
|
std::chrono::steady_clock::time_point secret_result_deadline_{}; // auto-wipe the buffer at this time
|
||||||
|
std::function<void(const std::string&)> copy_secret_cb_; // routes "Copy secret" to the app
|
||||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const char* const kSecretConsoleCommands[] = {
|
|||||||
"importprivkey", "importwallet", "importmulti",
|
"importprivkey", "importwallet", "importmulti",
|
||||||
"z_importkey", "z_importviewingkey", "z_importwallet",
|
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||||
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
||||||
|
// Lite-wallet verbs (console_command_reference kLiteKeyCommands): these take a spending/viewing key
|
||||||
|
// or a password as their argument. Full-node has no bare import/encrypt/decrypt/unlock verb, so a
|
||||||
|
// flat match is safe.
|
||||||
|
"import", "timport", "encrypt", "decrypt", "unlock",
|
||||||
};
|
};
|
||||||
|
|
||||||
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
||||||
@@ -67,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode)
|
||||||
|
{
|
||||||
|
size_t end = 0;
|
||||||
|
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||||
|
if (name.empty()) return false;
|
||||||
|
if (fullNode) {
|
||||||
|
static const char* const kFull[] = {
|
||||||
|
"dumpprivkey", "z_exportkey", "z_exportviewingkey",
|
||||||
|
"z_exportmnemonic", "dumpwallet", "z_exportwallet",
|
||||||
|
};
|
||||||
|
for (const char* s : kFull) if (name == s) return true;
|
||||||
|
} else {
|
||||||
|
// Lite backend verbs whose output is the seed phrase / private keys.
|
||||||
|
static const char* const kLite[] = { "seed", "export" };
|
||||||
|
for (const char* s : kLite) if (name == s) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::string RedactConsoleCommand(const std::string& cmd)
|
std::string RedactConsoleCommand(const std::string& cmd)
|
||||||
{
|
{
|
||||||
size_t end = 0;
|
size_t end = 0;
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ bool ShouldPrintDaemonErrorLine(bool wasError, bool isError,
|
|||||||
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||||
|
|
||||||
|
// True when the command's RESULT (not its args) reveals key material — dumpprivkey / z_exportkey /
|
||||||
|
// z_exportviewingkey / z_exportmnemonic / dumpwallet / z_exportwallet on the full node, or the lite
|
||||||
|
// `seed` / `export` verbs. Used to mask the output in the console scrollback. `fullNode` selects the
|
||||||
|
// command set (the bare lite verbs `seed`/`export` must not match full-node commands).
|
||||||
|
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode);
|
||||||
|
|
||||||
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||||
std::string RedactConsoleCommand(const std::string& cmd);
|
std::string RedactConsoleCommand(const std::string& cmd);
|
||||||
|
|||||||
@@ -445,11 +445,20 @@ private:
|
|||||||
|
|
||||||
static void renderDone(float, const Progress& p) {
|
static void renderDone(float, const Progress& p) {
|
||||||
using namespace material;
|
using namespace material;
|
||||||
if (!s_notified) { s_installed_flag = true; s_notified = true; }
|
if (!s_notified) {
|
||||||
|
s_installed_flag = true;
|
||||||
|
s_notified = true;
|
||||||
|
// Record a persistent "restart pending" reminder so it isn't lost if the user clicks Later.
|
||||||
|
if (s_app) s_app->markDaemonRestartPending();
|
||||||
|
}
|
||||||
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
|
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
|
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
// Lead with a warning-level headline that a restart is required, then the detail note.
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
|
||||||
|
Type().textColored(TypeStyle::Subtitle2, Warning(), TR("daemon_update_restart_needed"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (s_app->isUsingEmbeddedDaemon()) {
|
if (s_app->isUsingEmbeddedDaemon()) {
|
||||||
@@ -459,6 +468,7 @@ private:
|
|||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
|
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_later_hint"));
|
||||||
} else {
|
} else {
|
||||||
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
|
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ static void fetchBlockDetail(App* app, int height) {
|
|||||||
auto hashResult = rpc->call("getblockhash", {height});
|
auto hashResult = rpc->call("getblockhash", {height});
|
||||||
std::string hash = hashResult.get<std::string>();
|
std::string hash = hashResult.get<std::string>();
|
||||||
result = rpc->call("getblock", {hash});
|
result = rpc->call("getblock", {hash});
|
||||||
} catch (const std::exception& e) { error = e.what(); }
|
} catch (const std::exception& e) { error = util::friendlyRpcError(e.what()); }
|
||||||
return [result, error]() {
|
return [result, error]() {
|
||||||
applyBlockDetailResult(result, error);
|
applyBlockDetailResult(result, error);
|
||||||
};
|
};
|
||||||
@@ -383,19 +383,27 @@ static void navigateToHash(App* app, const std::string& hash) {
|
|||||||
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
|
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
|
||||||
json blockResult, txResult;
|
json blockResult, txResult;
|
||||||
bool gotBlock = false, gotTx = false;
|
bool gotBlock = false, gotTx = false;
|
||||||
|
bool rpcFailed = false; std::string rpcErr;
|
||||||
|
// Distinguish a genuine "not found" (RPC -5/-8) from a transport/timeout failure, so a flaky
|
||||||
|
// connection isn't reported as "no block or transaction found".
|
||||||
|
auto noteErr = [&](const std::exception& e) {
|
||||||
|
const auto* re = dynamic_cast<const rpc::RpcError*>(&e);
|
||||||
|
if (re && (re->code == -5 || re->code == -8)) return; // not-found: a real negative answer
|
||||||
|
if (!rpcFailed) { rpcFailed = true; rpcErr = e.what(); }
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||||
blockResult = rpc->call("getblock", {hash});
|
blockResult = rpc->call("getblock", {hash});
|
||||||
gotBlock = true;
|
gotBlock = true;
|
||||||
} catch (...) {}
|
} catch (const std::exception& e) { noteErr(e); }
|
||||||
if (!gotBlock) {
|
if (!gotBlock) {
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||||
txResult = rpc->call("getrawtransaction", {hash, 1});
|
txResult = rpc->call("getrawtransaction", {hash, 1});
|
||||||
gotTx = true;
|
gotTx = true;
|
||||||
} catch (...) {}
|
} catch (const std::exception& e) { noteErr(e); }
|
||||||
}
|
}
|
||||||
return [blockResult, txResult, gotBlock, gotTx]() {
|
return [blockResult, txResult, gotBlock, gotTx, rpcFailed, rpcErr]() {
|
||||||
s_search_loading = false;
|
s_search_loading = false;
|
||||||
if (gotBlock) {
|
if (gotBlock) {
|
||||||
int h = blockResult.value("height", -1);
|
int h = blockResult.value("height", -1);
|
||||||
@@ -407,6 +415,8 @@ static void navigateToHash(App* app, const std::string& hash) {
|
|||||||
s_tx_loading = false;
|
s_tx_loading = false;
|
||||||
s_expanded_tx_idx = 0;
|
s_expanded_tx_idx = 0;
|
||||||
s_show_detail_modal = false;
|
s_show_detail_modal = false;
|
||||||
|
} else if (rpcFailed) {
|
||||||
|
s_search_error = std::string(TR("explorer_lookup_failed")) + ": " + util::friendlyRpcError(rpcErr);
|
||||||
} else {
|
} else {
|
||||||
s_search_error = TR("explorer_hash_not_found");
|
s_search_error = TR("explorer_hash_not_found");
|
||||||
}
|
}
|
||||||
@@ -836,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
|
|
||||||
std::map<int, BlockSummary> cachedBlocks;
|
std::map<int, BlockSummary> cachedBlocks;
|
||||||
if (!pageHeights.empty()) {
|
if (!pageHeights.empty()) {
|
||||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
// Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame.
|
||||||
|
cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
||||||
@@ -880,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
}
|
}
|
||||||
std::map<int, BlockSummary> nextCachedBlocks;
|
std::map<int, BlockSummary> nextCachedBlocks;
|
||||||
if (!nextPageHeights.empty()) {
|
if (!nextPageHeights.empty()) {
|
||||||
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
|
nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front());
|
||||||
}
|
}
|
||||||
for (int height : nextPageHeights) {
|
for (int height : nextPageHeights) {
|
||||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "../../rpc/rpc_worker.h"
|
#include "../../rpc/rpc_worker.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/export_filename.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -117,7 +118,7 @@ void ExportAllKeysDialog::render(App* app)
|
|||||||
} else if (!s_status.empty()) {
|
} else if (!s_status.empty()) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", s_status.c_str());
|
ImGui::TextColored(material::ErrorVec4(), "%s", s_status.c_str());
|
||||||
ImGui::PopTextWrapPos();
|
ImGui::PopTextWrapPos();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +161,10 @@ void ExportAllKeysDialog::render(App* app)
|
|||||||
if (s_include_t) {
|
if (s_include_t) {
|
||||||
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
||||||
}
|
}
|
||||||
std::string filename(s_filename);
|
// Sanitize the typed filename (basename only, no traversal) and reflect it back in the
|
||||||
|
// buffer so the user sees exactly what gets written. Mirrors the CSV export dialog.
|
||||||
|
std::string filename = util::sanitizeExportFilename(s_filename, ".txt", "dragonx_keys.txt");
|
||||||
|
snprintf(s_filename, sizeof(s_filename), "%s", filename.c_str());
|
||||||
|
|
||||||
// Run all key exports on worker thread
|
// Run all key exports on worker thread
|
||||||
if (app->worker()) {
|
if (app->worker()) {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
#include "../../app.h"
|
#include "../../app.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/export_filename.h"
|
||||||
#include "../../data/transaction_csv.h"
|
#include "../../data/transaction_csv.h"
|
||||||
|
#include "transactions_tab.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -27,12 +31,34 @@ static bool s_open = false;
|
|||||||
static char s_filename[256] = "";
|
static char s_filename[256] = "";
|
||||||
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
||||||
static std::string s_error; // error message on failure; empty otherwise
|
static std::string s_error; // error message on failure; empty otherwise
|
||||||
|
static int s_scope = 0; // 0 = all, 1 = current History filter, 2 = date range
|
||||||
|
static char s_from[16] = ""; // YYYY-MM-DD (date-range scope)
|
||||||
|
static char s_to[16] = "";
|
||||||
|
|
||||||
|
// Parse a YYYY-MM-DD string to the local-time start-of-day unix seconds. Returns false on empty/invalid.
|
||||||
|
static bool parseYmd(const char* s, std::int64_t& outStartOfDay) {
|
||||||
|
if (!s || !*s) return false;
|
||||||
|
int y = 0, mo = 0, d = 0;
|
||||||
|
if (std::sscanf(s, "%d-%d-%d", &y, &mo, &d) != 3) return false;
|
||||||
|
if (y < 1970 || y > 9999 || mo < 1 || mo > 12 || d < 1 || d > 31) return false;
|
||||||
|
std::tm tm{}; tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; tm.tm_isdst = -1;
|
||||||
|
std::time_t t = std::mktime(&tm);
|
||||||
|
if (t == (std::time_t)-1) return false;
|
||||||
|
// Reject rollover (e.g. Feb 31 → Mar) by requiring the normalized fields to round-trip.
|
||||||
|
if (tm.tm_year != y - 1900 || tm.tm_mon != mo - 1 || tm.tm_mday != d) return false;
|
||||||
|
outStartOfDay = (std::int64_t)t;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void ExportTransactionsDialog::show()
|
void ExportTransactionsDialog::show()
|
||||||
{
|
{
|
||||||
s_open = true;
|
s_open = true;
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
|
// Pre-select the current History filter only when one is actually applied.
|
||||||
|
s_scope = GetTransactionsFilterSnapshot().active ? 1 : 0;
|
||||||
|
s_from[0] = '\0';
|
||||||
|
s_to[0] = '\0';
|
||||||
|
|
||||||
// Generate default filename with timestamp
|
// Generate default filename with timestamp
|
||||||
std::time_t now = std::time(nullptr);
|
std::time_t now = std::time(nullptr);
|
||||||
@@ -74,8 +100,64 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (!m::BeginOverlayDialog(ov)) return;
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
const auto& state = app->getWalletState();
|
const auto& state = app->getWalletState();
|
||||||
|
const TxFilterSnapshot& snap = GetTransactionsFilterSnapshot();
|
||||||
|
const bool filterUsable = snap.valid && snap.active && snap.txRevision == state.tx_revision;
|
||||||
|
|
||||||
ImGui::Text(TR("export_tx_count"), state.transactions.size());
|
// ---- Export scope ----
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("export_tx_scope"));
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_all"), &s_scope, 0);
|
||||||
|
if (!filterUsable) ImGui::BeginDisabled();
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_filter"), &s_scope, 1);
|
||||||
|
if (!filterUsable) ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !filterUsable)
|
||||||
|
m::Tooltip("%s", TR("export_tx_scope_filter_none"));
|
||||||
|
if (s_scope == 1 && filterUsable && !snap.description.empty()) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "(%s)", snap.description.c_str());
|
||||||
|
}
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_range"), &s_scope, 2);
|
||||||
|
|
||||||
|
std::int64_t fromT = 0, toT = 0; bool fromOk = false, toOk = false, rangeBad = false;
|
||||||
|
if (s_scope == 2) {
|
||||||
|
ImGui::Indent();
|
||||||
|
const float dw = 130.0f * Layout::dpiScale();
|
||||||
|
ImGui::SetNextItemWidth(dw);
|
||||||
|
ImGui::InputTextWithHint("##exfrom", TR("export_tx_range_from"), s_from, sizeof(s_from));
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetNextItemWidth(dw);
|
||||||
|
ImGui::InputTextWithHint("##exto", TR("export_tx_range_to"), s_to, sizeof(s_to));
|
||||||
|
fromOk = parseYmd(s_from, fromT);
|
||||||
|
toOk = parseYmd(s_to, toT);
|
||||||
|
if (toOk) toT += 24 * 3600 - 1; // inclusive end-of-day
|
||||||
|
if ((s_from[0] && !fromOk) || (s_to[0] && !toOk)) rangeBad = true;
|
||||||
|
if (fromOk && toOk && fromT > toT) rangeBad = true;
|
||||||
|
if (rangeBad)
|
||||||
|
ImGui::TextColored(m::ErrorVec4(), "%s", TR("export_tx_range_invalid"));
|
||||||
|
else
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("export_tx_range_pending_note"));
|
||||||
|
ImGui::Unindent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the subset for the chosen scope (scopeOk=false ⇒ can't export: stale snapshot / bad range).
|
||||||
|
std::vector<size_t> subsetIdx; bool scopeOk = true;
|
||||||
|
if (s_scope == 1) {
|
||||||
|
if (!filterUsable) scopeOk = false;
|
||||||
|
else for (size_t i : snap.indices) if (i < state.transactions.size()) subsetIdx.push_back(i);
|
||||||
|
} else if (s_scope == 2) {
|
||||||
|
if (rangeBad) scopeOk = false;
|
||||||
|
else for (size_t i = 0; i < state.transactions.size(); ++i) {
|
||||||
|
const auto& tx = state.transactions[i];
|
||||||
|
if (tx.timestamp <= 0) continue; // pending rows have no date
|
||||||
|
if (fromOk && tx.timestamp < fromT) continue;
|
||||||
|
if (toOk && tx.timestamp > toT) continue;
|
||||||
|
subsetIdx.push_back(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (size_t i = 0; i < state.transactions.size(); ++i) subsetIdx.push_back(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Text(TR("export_tx_count"), scopeOk ? subsetIdx.size() : (size_t)0);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Filename + save-location hint.
|
// Filename + save-location hint.
|
||||||
@@ -94,6 +176,8 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
||||||
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
||||||
widgets::AddressCopyField("##exportedpath", s_saved_path);
|
widgets::AddressCopyField("##exportedpath", s_saved_path);
|
||||||
|
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
|
||||||
|
util::Platform::openFolder(util::Platform::getConfigDir());
|
||||||
} else if (!s_error.empty()) {
|
} else if (!s_error.empty()) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -109,14 +193,29 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (doExport) {
|
if (doExport) {
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
if (state.transactions.empty()) {
|
if (!scopeOk) {
|
||||||
|
// Stale filter snapshot (tx list changed) or an invalid date range — never silently export all.
|
||||||
|
Notifications::instance().warning(s_scope == 1 ? TR("export_tx_scope_stale") : TR("export_tx_range_invalid"));
|
||||||
|
} else if (subsetIdx.empty()) {
|
||||||
Notifications::instance().warning(TR("export_tx_none"));
|
Notifications::instance().warning(TR("export_tx_none"));
|
||||||
} else {
|
} else {
|
||||||
std::string configDir = util::Platform::getConfigDir();
|
std::string configDir = util::Platform::getConfigDir();
|
||||||
std::string filepath = configDir + "/" + s_filename;
|
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
||||||
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
|
// the config dir by a pasted path).
|
||||||
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
|
std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
|
||||||
const std::string csv = data::formatTransactionsCsv(state.transactions);
|
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
||||||
|
std::string filepath = configDir + "/" + safeName;
|
||||||
|
// Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
|
||||||
|
// (temp + fsync + rename) so a disk-full failure is reported instead of a truncated CSV.
|
||||||
|
std::string csv;
|
||||||
|
if (s_scope == 0) {
|
||||||
|
csv = data::formatTransactionsCsv(state.transactions);
|
||||||
|
} else {
|
||||||
|
std::vector<TransactionInfo> subset;
|
||||||
|
subset.reserve(subsetIdx.size());
|
||||||
|
for (size_t i : subsetIdx) subset.push_back(state.transactions[i]);
|
||||||
|
csv = data::formatTransactionsCsv(subset);
|
||||||
|
}
|
||||||
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
||||||
s_error = TR("export_tx_file_fail");
|
s_error = TR("export_tx_file_fail");
|
||||||
Notifications::instance().error(TR("export_tx_file_fail"));
|
Notifications::instance().error(TR("export_tx_file_fail"));
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ void KeyExportDialog::render(App* app)
|
|||||||
ImGui::Text("%s", key_label);
|
ImGui::Text("%s", key_label);
|
||||||
|
|
||||||
if (s_fetching) {
|
if (s_fetching) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching"));
|
ImGui::TextColored(material::WarningVec4(), "%s%s", TR("key_export_fetching_base"), material::LoadingDots());
|
||||||
} else if (!s_error.empty()) {
|
} else if (!s_error.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str());
|
ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error.c_str());
|
||||||
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
|
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
@@ -132,7 +132,17 @@ void KeyExportDialog::render(App* app)
|
|||||||
} else if (s_key.empty() && s_address.length() < 26) {
|
} else if (s_key.empty() && s_address.length() < 26) {
|
||||||
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
|
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
|
||||||
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
|
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("invalid_address"));
|
ImGui::TextColored(material::ErrorVec4(), "%s", TR("invalid_address"));
|
||||||
|
} else if (s_key.empty() && app->getWalletState().isLocked()) {
|
||||||
|
// Reveal would just fail with a locked-wallet RPC error — say so and offer to unlock.
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||||
|
ImGui::TextWrapped("%s", TR("key_export_unlock_first"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (material::TactileButton(TR("lite_unlock"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
|
if (app->liteWallet()) app->requestLiteUnlock();
|
||||||
|
else s_open = false; // full node: closing reveals the lock screen already shown while locked
|
||||||
|
}
|
||||||
} else if (s_key.empty()) {
|
} else if (s_key.empty()) {
|
||||||
// Show button to fetch key
|
// Show button to fetch key
|
||||||
if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
@@ -173,7 +183,7 @@ void KeyExportDialog::render(App* app)
|
|||||||
s_key = found;
|
s_key = found;
|
||||||
s_show_key = wantViewing; // viewing keys are less sensitive
|
s_show_key = wantViewing; // viewing keys are less sensitive
|
||||||
} else {
|
} else {
|
||||||
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : r.error;
|
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : util::friendlyRpcError(r.error);
|
||||||
}
|
}
|
||||||
wallet::secureWipeLiteSecret(found);
|
wallet::secureWipeLiteSecret(found);
|
||||||
s_fetching = false;
|
s_fetching = false;
|
||||||
|
|||||||
@@ -991,6 +991,30 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
|||||||
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||||
|
|
||||||
|
// Persistent error caption (a crashed/AV-blocked/failed miner otherwise leaves a button that
|
||||||
|
// "did nothing" once its toast faded). Normal ImGui flow, OUTSIDE the hand-drawn button card.
|
||||||
|
const std::string& merr = app->miningLastError();
|
||||||
|
if (!merr.empty() && !app->isMiningToggleInProgress()) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushTextWrapPos(cardMin.x + availWidth);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
|
||||||
|
ImGui::TextWrapped("%s", merr.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
|
||||||
|
ImGui::TextUnformatted(TR("mining_error_retry_hint"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (app->miningErrorIsAntivirus()) {
|
||||||
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
|
if (ImGui::SmallButton(TR("mining_error_av_help"))) app->showAntivirusHelp();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
|
if (ImGui::SmallButton(TR("mining_error_dismiss"))) app->clearMiningError();
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, gap));
|
ImGui::Dummy(ImVec2(0, gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,18 +191,18 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
|||||||
};
|
};
|
||||||
|
|
||||||
snprintf(valBuf, sizeof(valBuf), "+%.4f", minedToday);
|
snprintf(valBuf, sizeof(valBuf), "+%.4f", minedToday);
|
||||||
snprintf(subBuf2, sizeof(subBuf2), "(%d txn)", minedTodayCount);
|
snprintf(subBuf2, sizeof(subBuf2), TR("mining_txn_count"), minedTodayCount);
|
||||||
char todayVal[64], todaySub[64];
|
char todayVal[64], todaySub[64];
|
||||||
strncpy(todayVal, valBuf, sizeof(todayVal));
|
strncpy(todayVal, valBuf, sizeof(todayVal));
|
||||||
strncpy(todaySub, subBuf2, sizeof(todaySub));
|
strncpy(todaySub, subBuf2, sizeof(todaySub));
|
||||||
|
|
||||||
char yesterdayVal[64], yesterdaySub[64];
|
char yesterdayVal[64], yesterdaySub[64];
|
||||||
snprintf(yesterdayVal, sizeof(yesterdayVal), "+%.4f", minedYesterday);
|
snprintf(yesterdayVal, sizeof(yesterdayVal), "+%.4f", minedYesterday);
|
||||||
snprintf(yesterdaySub, sizeof(yesterdaySub), "(%d txn)", minedYesterdayCount);
|
snprintf(yesterdaySub, sizeof(yesterdaySub), TR("mining_txn_count"), minedYesterdayCount);
|
||||||
|
|
||||||
char allVal[64], allSub[64];
|
char allVal[64], allSub[64];
|
||||||
snprintf(allVal, sizeof(allVal), "+%.4f", minedAllTime);
|
snprintf(allVal, sizeof(allVal), "+%.4f", minedAllTime);
|
||||||
snprintf(allSub, sizeof(allSub), "(%d txn)", minedAllTimeCount);
|
snprintf(allSub, sizeof(allSub), TR("mining_txn_count"), minedAllTimeCount);
|
||||||
|
|
||||||
char estVal[64];
|
char estVal[64];
|
||||||
if (estActive)
|
if (estActive)
|
||||||
@@ -324,6 +324,11 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
|||||||
|
|
||||||
// -- Mining Address --
|
// -- Mining Address --
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X, cy), OnSurfaceMedium(), TR("mining_mining_addr"));
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X, cy), OnSurfaceMedium(), TR("mining_mining_addr"));
|
||||||
|
{
|
||||||
|
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, TR("mining_mining_addr"));
|
||||||
|
if (material::IsRectHovered(ImVec2(col3X, cy), ImVec2(col3X + lblSz.x, cy + lblSz.y)))
|
||||||
|
material::Tooltip("%s", TR("mining_addr_explainer"));
|
||||||
|
}
|
||||||
std::string mining_address = "";
|
std::string mining_address = "";
|
||||||
for (const auto& addr : state.addresses) {
|
for (const auto& addr : state.addresses) {
|
||||||
if (addr.type == "transparent" && !addr.address.empty()) {
|
if (addr.type == "transparent" && !addr.address.empty()) {
|
||||||
@@ -351,6 +356,12 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
|||||||
ImVec2(Layout::spacingMd(), 4 * dp))) {
|
ImVec2(Layout::spacingMd(), 4 * dp))) {
|
||||||
Notifications::instance().info(TR("mining_address_copied"));
|
Notifications::instance().info(TR("mining_address_copied"));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No mining (transparent) address yet — explain where rewards go instead of a blank.
|
||||||
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X + valOffX, cy), OnSurfaceMedium(), TR("mining_addr_none"));
|
||||||
|
ImVec2 emSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, TR("mining_addr_none"));
|
||||||
|
if (material::IsRectHovered(ImVec2(col3X + valOffX, cy), ImVec2(col3X + valOffX + emSz.x, cy + emSz.y)))
|
||||||
|
material::Tooltip("%s", TR("mining_addr_none_tip"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
||||||
ImGui::Dummy(ImVec2(totalW, 0));
|
ImGui::Dummy(ImVec2(totalW, 0));
|
||||||
|
|
||||||
|
bool payoutBadThisFrame = false; // set when the inline payout error is drawn; reserves a row below
|
||||||
|
|
||||||
// Pool URL + Worker inputs inline next to toggle (pool mode only)
|
// Pool URL + Worker inputs inline next to toggle (pool mode only)
|
||||||
if (s_pool_mode && soloMiningActive) {
|
if (s_pool_mode && soloMiningActive) {
|
||||||
// Solo mining is active — show disabled message instead of inputs
|
// Solo mining is active — show disabled message instead of inputs
|
||||||
@@ -427,6 +429,24 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistent inline payout error — a hover-only tooltip is missed by anyone who doesn't hover,
|
||||||
|
// so outline the field in red and draw a short reason below it (draw-list only; no layout calls
|
||||||
|
// here, the cursor is hand-positioned). The field height is reserved at the end of the function.
|
||||||
|
{
|
||||||
|
const ImVec2 wMin = ImGui::GetItemRectMin();
|
||||||
|
const ImVec2 wMax = ImGui::GetItemRectMax();
|
||||||
|
const std::string wk(s_pool_worker);
|
||||||
|
if (!wk.empty() && wk != "x" && !util::isValidRecipientAddress(wk)) {
|
||||||
|
payoutBadThisFrame = true;
|
||||||
|
dl->AddRect(wMin, wMax, Error(), 4.0f * dp, 0, 1.5f * dp);
|
||||||
|
const char* msg = util::isTransparentAddress(wk)
|
||||||
|
? TR("mining_payout_needs_shielded_short") : TR("mining_payout_invalid_short");
|
||||||
|
std::string shown = material::TruncateToWidth(msg, capFont, capFont->LegacySize, wrkGroupW);
|
||||||
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(wMin.x, wMax.y + 1.0f * dp),
|
||||||
|
ReadableError(), shown.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Worker: Dropdown arrow button ---
|
// --- Worker: Dropdown arrow button ---
|
||||||
ImGui::SameLine(0, 0);
|
ImGui::SameLine(0, 0);
|
||||||
{
|
{
|
||||||
@@ -642,7 +662,9 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
|||||||
// Ensure cursor Y is at toggle bottom regardless of pool input widgets,
|
// Ensure cursor Y is at toggle bottom regardless of pool input widgets,
|
||||||
// so the cards below stay at the same position in both solo and pool modes.
|
// so the cards below stay at the same position in both solo and pool modes.
|
||||||
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
||||||
ImGui::Dummy(ImVec2(0, gap * 0.5f));
|
// Reserve a row for the inline payout error so it doesn't overlap the card below (cards shift down
|
||||||
|
// transiently while the error is shown).
|
||||||
|
ImGui::Dummy(ImVec2(0, gap * 0.5f + (payoutBadThisFrame ? capFont->LegacySize + 2.0f * dp : 0.0f)));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -243,11 +243,26 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
{
|
{
|
||||||
char uptBuf[64];
|
char uptBuf[64];
|
||||||
int64_t up = pm.uptime_sec;
|
int64_t up = pm.uptime_sec;
|
||||||
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "N/A");
|
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "%s", TR("grpc_na"));
|
||||||
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
|
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), TR("mining_uptime_ms"), (long long)(up / 60), (long long)(up % 60));
|
||||||
else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
|
else snprintf(uptBuf, sizeof(uptBuf), TR("mining_uptime_hm"), (long long)(up / 3600), (long long)((up % 3600) / 60));
|
||||||
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
|
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
|
||||||
}
|
}
|
||||||
|
// Connection status — replaces the old transient "Connecting" banner, with a timeout so a bad URL /
|
||||||
|
// blocked port flips from "Connecting" to "Can't reach pool" instead of spinning on red forever.
|
||||||
|
{
|
||||||
|
static constexpr int kPoolConnectTimeoutSec = 30;
|
||||||
|
const bool shares = pm.accepted + pm.rejected > 0;
|
||||||
|
const bool ok = pm.connected || shares || pm.hashrate_10s > 0;
|
||||||
|
const bool failed = pm.xmrig_running && !ok && pm.running_since > 0 &&
|
||||||
|
(std::time(nullptr) - pm.running_since) >= kPoolConnectTimeoutSec;
|
||||||
|
const char* stateText; ImU32 col;
|
||||||
|
if (ok) { stateText = TR("mining_status_connected"); col = Success(); }
|
||||||
|
else if (failed) { stateText = TR("mining_pool_unreachable"); col = Error(); }
|
||||||
|
else if (pm.xmrig_running) { stateText = TR("mining_status_connecting"); col = Warning(); }
|
||||||
|
else { stateText = TR("mining_status_stopped"); col = OnSurfaceDisabled(); }
|
||||||
|
metricRow(TR("mining_status_label"), stateText, col);
|
||||||
|
}
|
||||||
y += gap * 0.5f;
|
y += gap * 0.5f;
|
||||||
|
|
||||||
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
||||||
@@ -397,7 +412,7 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
|||||||
const float mRowH = sub1->LegacySize + 7 * dp;
|
const float mRowH = sub1->LegacySize + 7 * dp;
|
||||||
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
|
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
|
||||||
float leftContentH = pad * 0.75f + toggleH + gap
|
float leftContentH = pad * 0.75f + toggleH + gap
|
||||||
+ mRowH * 4.0f + gap * 0.5f
|
+ mRowH * 5.0f + gap * 0.5f // 4 metric rows + the connection-status row
|
||||||
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
|
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
|
||||||
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
|
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
|
||||||
|
|
||||||
|
|||||||
@@ -335,24 +335,9 @@ static void RenderMiningTabContent(App* app)
|
|||||||
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
|
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// POOL CONNECTION STATUS — inline indicator (pool mode, no log)
|
// (Pool connection status now lives as a persistent, timeout-aware row in RenderLeftPoolCard
|
||||||
// ================================================================
|
// (mining_stats.cpp) — the old transient "Connecting" banner that vanished once logs arrived and
|
||||||
if (s_pool_mode && state.pool_mining.log_lines.empty() && state.pool_mining.xmrig_running) {
|
// sat on red forever on failure was removed.)
|
||||||
ImFont* iconFont = Type().iconSmall();
|
|
||||||
const char* dotIcon = ICON_MD_CIRCLE;
|
|
||||||
ImU32 dotCol = state.pool_mining.connected ? WithAlpha(Success(), 200) : WithAlpha(Error(), 200);
|
|
||||||
const char* statusText = state.pool_mining.connected
|
|
||||||
? (state.pool_mining.pool_url.empty() ? TR("mining_connected") : state.pool_mining.pool_url.c_str())
|
|
||||||
: TR("mining_connecting");
|
|
||||||
|
|
||||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
||||||
ImVec2 dotSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, dotIcon);
|
|
||||||
dl->AddText(iconFont, iconFont->LegacySize, pos, dotCol, dotIcon);
|
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
|
||||||
ImVec2(pos.x + dotSz.x + 4 * dp, pos.y + (dotSz.y - capFont->LegacySize) * 0.5f),
|
|
||||||
OnSurfaceMedium(), statusText);
|
|
||||||
ImGui::Dummy(ImVec2(availWidth, capFont->LegacySize + 4 * dp));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ using namespace material;
|
|||||||
// Track selected peer for ban action
|
// Track selected peer for ban action
|
||||||
static int s_selected_peer_idx = -1;
|
static int s_selected_peer_idx = -1;
|
||||||
static int s_selected_banned_idx = -1;
|
static int s_selected_banned_idx = -1;
|
||||||
|
static bool s_confirm_clear_bans = false; // inline confirm for the bulk Clear-All-Bans action
|
||||||
|
|
||||||
// Helper: Extract IP without port
|
// Helper: Extract IP without port
|
||||||
static std::string ExtractIP(const std::string& addr)
|
static std::string ExtractIP(const std::string& addr)
|
||||||
@@ -775,9 +776,11 @@ void RenderPeersTab(App* app)
|
|||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||||
ImGui::SetClipboardText(peer.addr.c_str());
|
ImGui::SetClipboardText(peer.addr.c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
||||||
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
||||||
@@ -891,6 +894,7 @@ void RenderPeersTab(App* app)
|
|||||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||||
ImGui::SetClipboardText(banned.address.c_str());
|
ImGui::SetClipboardText(banned.address.c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("peers_unban"))) {
|
if (ImGui::MenuItem(TR("peers_unban"))) {
|
||||||
app->unbanPeer(banned.address);
|
app->unbanPeer(banned.address);
|
||||||
@@ -931,10 +935,25 @@ void RenderPeersTab(App* app)
|
|||||||
// ================================================================
|
// ================================================================
|
||||||
if (s_show_banned && !state.bannedPeers.empty()) {
|
if (s_show_banned && !state.bannedPeers.empty()) {
|
||||||
ImGui::BeginDisabled(!app->isConnected());
|
ImGui::BeginDisabled(!app->isConnected());
|
||||||
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button"))) {
|
if (!s_confirm_clear_bans) {
|
||||||
|
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button")))
|
||||||
|
s_confirm_clear_bans = true; // bulk action takes a confirming second click
|
||||||
|
} else {
|
||||||
|
char cb[96];
|
||||||
|
snprintf(cb, sizeof(cb), TR("peers_clear_bans_confirm"), (int)state.bannedPeers.size());
|
||||||
|
ImGui::TextColored(material::WarningVec4(), "%s", cb);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileSmallButton(TR("peers_clear_bans_confirm_yes"), S.resolveFont("button"))) {
|
||||||
app->clearBans();
|
app->clearBans();
|
||||||
|
s_confirm_clear_bans = false;
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileSmallButton(TR("cancel"), S.resolveFont("button")))
|
||||||
|
s_confirm_clear_bans = false;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
} else {
|
||||||
|
s_confirm_clear_bans = false; // no stale confirm across tab toggles / after bans clear
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::EndChild(); // ##PeersScroll
|
ImGui::EndChild(); // ##PeersScroll
|
||||||
|
|||||||
@@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
|
|||||||
} else {
|
} else {
|
||||||
s_generating_address = true;
|
s_generating_address = true;
|
||||||
if (s_addr_type_filter != 2) {
|
if (s_addr_type_filter != 2) {
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
@@ -400,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float
|
|||||||
s_cached_qr_data.clear();
|
s_cached_qr_data.clear();
|
||||||
} else {
|
} else {
|
||||||
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ static uintptr_t s_qr_texture = 0;
|
|||||||
static int s_qr_width = 0;
|
static int s_qr_width = 0;
|
||||||
static int s_qr_height = 0;
|
static int s_qr_height = 0;
|
||||||
static bool s_uri_dirty = true;
|
static bool s_uri_dirty = true;
|
||||||
|
static bool s_creating_address = false; // true while a Create-shielded-address RPC is in flight
|
||||||
|
|
||||||
// Helper to build payment URI
|
// Helper to build payment URI
|
||||||
static std::string buildPaymentUri()
|
static std::string buildPaymentUri()
|
||||||
@@ -48,6 +49,7 @@ void RequestPaymentDialog::show(const std::string& address)
|
|||||||
s_label[0] = '\0';
|
s_label[0] = '\0';
|
||||||
s_payment_uri.clear();
|
s_payment_uri.clear();
|
||||||
s_uri_dirty = true;
|
s_uri_dirty = true;
|
||||||
|
s_creating_address = false;
|
||||||
|
|
||||||
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
|
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
|
||||||
if (!address.empty() && !util::isTransparentAddress(address)) {
|
if (!address.empty() && !util::isTransparentAddress(address)) {
|
||||||
@@ -67,6 +69,7 @@ void RequestPaymentDialog::show(const std::string& address)
|
|||||||
void RequestPaymentDialog::hide()
|
void RequestPaymentDialog::hide()
|
||||||
{
|
{
|
||||||
s_open = false;
|
s_open = false;
|
||||||
|
s_creating_address = false;
|
||||||
s_payment_uri.clear();
|
s_payment_uri.clear();
|
||||||
if (s_qr_texture != 0) {
|
if (s_qr_texture != 0) {
|
||||||
FreeQRTexture(s_qr_texture);
|
FreeQRTexture(s_qr_texture);
|
||||||
@@ -100,17 +103,55 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Address selection
|
// Address selection
|
||||||
|
const bool noShielded = state.z_addresses.empty();
|
||||||
|
if (noShielded) {
|
||||||
|
// Nothing to request to yet — offer to create a shielded address instead of dead-ending.
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||||
|
ImGui::TextWrapped("%s", TR("request_no_shielded"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (s_creating_address) {
|
||||||
|
ImGui::BeginDisabled();
|
||||||
|
material::TactileButton(TR("generating"), ImVec2(0, 0), S.resolveFont(actionBtn.font));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
} else {
|
||||||
|
ImGui::BeginDisabled(!app->isConnected());
|
||||||
|
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
||||||
|
if (app->isConnected()) {
|
||||||
|
s_creating_address = true;
|
||||||
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
|
s_creating_address = false;
|
||||||
|
if (addr.empty()) {
|
||||||
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
|
} else {
|
||||||
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
|
// Pre-select the new address if the dialog is still open and none chosen.
|
||||||
|
if (s_open && s_address[0] == '\0') {
|
||||||
|
strncpy(s_address, addr.c_str(), sizeof(s_address) - 1);
|
||||||
|
s_address[sizeof(s_address) - 1] = '\0';
|
||||||
|
s_uri_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ImGui::Text("%s", TR("request_receive_address"));
|
ImGui::Text("%s", TR("request_receive_address"));
|
||||||
|
|
||||||
std::string addr_display = s_address[0] ? s_address : TR("request_select_address");
|
std::string addr_display = s_address[0] ? s_address : std::string(TR("request_select_address"));
|
||||||
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
||||||
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
|
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
|
||||||
}
|
}
|
||||||
|
if (s_address[0]) {
|
||||||
|
std::string l = app->getAddressLabel(s_address);
|
||||||
|
if (!l.empty()) addr_display = l + " " + addr_display;
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::SetNextItemWidth(-1);
|
ImGui::SetNextItemWidth(-1);
|
||||||
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
|
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
|
||||||
// Z-addresses
|
|
||||||
if (!state.z_addresses.empty()) {
|
|
||||||
ImGui::TextDisabled("%s", TR("request_shielded_addrs"));
|
ImGui::TextDisabled("%s", TR("request_shielded_addrs"));
|
||||||
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
||||||
const auto& addr = state.z_addresses[i];
|
const auto& addr = state.z_addresses[i];
|
||||||
@@ -118,18 +159,19 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
if (label.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
if (label.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
||||||
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
|
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
|
||||||
}
|
}
|
||||||
|
std::string clbl = app->getAddressLabel(addr.address);
|
||||||
|
std::string shown = (clbl.empty() ? label : (clbl + " " + label)) + "##" + addr.address;
|
||||||
|
|
||||||
if (ImGui::Selectable(label.c_str(), addr.address == s_address)) {
|
if (ImGui::Selectable(shown.c_str(), addr.address == s_address)) {
|
||||||
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
|
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
|
||||||
s_uri_dirty = true;
|
s_uri_dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
|
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
|
||||||
// could never be paid.
|
// could never be paid.
|
||||||
ImGui::EndCombo();
|
ImGui::EndCombo();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include "../layout.h" // Layout::dpiScale()
|
#include "../layout.h" // Layout::dpiScale()
|
||||||
@@ -15,19 +16,31 @@
|
|||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
// Best-effort zero of a seed-word vector (each word holds part of the phrase). Call after rendering.
|
||||||
|
inline void WipeSeedWords(std::vector<std::string>& words)
|
||||||
|
{
|
||||||
|
for (auto& w : words) { if (!w.empty()) sodium_memzero(&w[0], w.size()); w.clear(); }
|
||||||
|
words.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Split a whitespace-separated seed phrase into its individual words.
|
// Split a whitespace-separated seed phrase into its individual words.
|
||||||
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
||||||
{
|
{
|
||||||
std::vector<std::string> words;
|
std::vector<std::string> words;
|
||||||
|
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
|
||||||
std::string cur;
|
std::string cur;
|
||||||
for (char c : phrase) {
|
for (char c : phrase) {
|
||||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||||
if (!cur.empty()) { words.push_back(cur); cur.clear(); }
|
if (!cur.empty()) {
|
||||||
|
words.push_back(cur);
|
||||||
|
sodium_memzero(&cur[0], cur.size()); // zero the scratch copy before reuse
|
||||||
|
cur.clear();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cur.push_back(c);
|
cur.push_back(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cur.empty()) words.push_back(cur);
|
if (!cur.empty()) { words.push_back(cur); sodium_memzero(&cur[0], cur.size()); cur.clear(); }
|
||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +58,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
|||||||
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
||||||
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
||||||
if (need > colStride) colStride = need;
|
if (need > colStride) colStride = need;
|
||||||
|
sodium_memzero(cell, sizeof(cell)); // the cell held a seed word
|
||||||
}
|
}
|
||||||
for (size_t i = 0; i < words.size(); ++i) {
|
for (size_t i = 0; i < words.size(); ++i) {
|
||||||
char cell[96];
|
char cell[96];
|
||||||
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
|||||||
ImGui::TextUnformatted(cell);
|
ImGui::TextUnformatted(cell);
|
||||||
if ((i % 4) != 3 && i + 1 < words.size())
|
if ((i % 4) != 3 && i + 1 < words.size())
|
||||||
ImGui::SameLine(((i % 4) + 1) * colStride);
|
ImGui::SameLine(((i % 4) + 1) * colStride);
|
||||||
|
sodium_memzero(cell, sizeof(cell));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
-46
@@ -14,6 +14,8 @@
|
|||||||
#include "../../util/address_validation.h"
|
#include "../../util/address_validation.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/text_format.h"
|
#include "../../util/text_format.h"
|
||||||
|
#include "../../util/amount_format.h" // toSats / canAfford — integer-satoshi affordability check
|
||||||
|
#include "../../rpc/rpc_failure.h" // isSendMaybeInFlight — lost-reply (outcome unknown) send tag
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "contact_picker.h"
|
#include "contact_picker.h"
|
||||||
@@ -77,7 +79,6 @@ static constexpr double FEE_LOW = DRAGONX_DEFAULT_FEE * 0.5;
|
|||||||
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
|
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
|
||||||
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
|
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
|
||||||
|
|
||||||
static bool s_clear_confirm_pending = false;
|
|
||||||
|
|
||||||
// Source dropdown preview string
|
// Source dropdown preview string
|
||||||
static std::string s_source_preview;
|
static std::string s_source_preview;
|
||||||
@@ -268,8 +269,12 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
const char* tag = isZ ? "[Z]" : "[T]";
|
const char* tag = isZ ? "[Z]" : "[T]";
|
||||||
std::string trunc = util::truncateMiddle(addr.address,
|
std::string trunc = util::truncateMiddle(addr.address,
|
||||||
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
||||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
std::string lbl = app->getAddressLabel(addr.address);
|
||||||
tag, trunc.c_str(),
|
if (!lbl.empty())
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
else
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
|
||||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
s_source_preview = buf;
|
s_source_preview = buf;
|
||||||
} else if (s_from_address[0] != '\0') {
|
} else if (s_from_address[0] != '\0') {
|
||||||
@@ -313,8 +318,12 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
const char* tag = isZ ? "[Z]" : "[T]";
|
const char* tag = isZ ? "[Z]" : "[T]";
|
||||||
|
|
||||||
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
||||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
std::string lbl = app->getAddressLabel(addr.address);
|
||||||
tag, trunc.c_str(),
|
if (!lbl.empty())
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
else
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
|
||||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
|
||||||
ImGui::PushID(static_cast<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
@@ -324,8 +333,11 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
addr.address.c_str());
|
addr.address.c_str());
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
material::Tooltip("%s\n%s",
|
if (!lbl.empty())
|
||||||
addr.address.c_str(),
|
material::Tooltip("%s\n%s\n%s", lbl.c_str(), addr.address.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
else
|
||||||
|
material::Tooltip("%s\n%s", addr.address.c_str(),
|
||||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
@@ -335,6 +347,20 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
ImGui::EndCombo();
|
ImGui::EndCombo();
|
||||||
}
|
}
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
|
|
||||||
|
// Warn when the chosen source is a transparent address: spending from it is not private on this
|
||||||
|
// ac_private chain (sender, amount and recipient become visible on-chain).
|
||||||
|
if (app->isConnected() && s_from_address[0] != '\0') {
|
||||||
|
bool srcT = (s_selected_from_idx >= 0 && s_selected_from_idx < (int)state.addresses.size())
|
||||||
|
? (state.addresses[s_selected_from_idx].type != "shielded")
|
||||||
|
: IsValidTransparentAddr(s_from_address);
|
||||||
|
if (srcT) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||||
|
Type().textWrapped(TypeStyle::Caption, TR("send_transparent_source_warn"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -891,8 +917,9 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
// since Review. Don't broadcast a now-invalid transaction.
|
// since Review. Don't broadcast a now-invalid transaction.
|
||||||
const auto& liveState = app->getWalletState();
|
const auto& liveState = app->getWalletState();
|
||||||
double liveAvail = GetAvailableBalance(app);
|
double liveAvail = GetAvailableBalance(app);
|
||||||
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
|
if (!app->isConnected() || liveState.sync.syncing ||
|
||||||
(s_amount + s_fee) > liveAvail) {
|
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
|
||||||
|
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
||||||
s_tx_status = TR("send_cannot_send_now");
|
s_tx_status = TR("send_cannot_send_now");
|
||||||
s_status_success = false;
|
s_status_success = false;
|
||||||
s_status_timestamp = ImGui::GetTime();
|
s_status_timestamp = ImGui::GetTime();
|
||||||
@@ -930,6 +957,18 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
s_amount = 0.0;
|
s_amount = 0.0;
|
||||||
s_memo[0] = '\0';
|
s_memo[0] = '\0';
|
||||||
s_send_max = false;
|
s_send_max = false;
|
||||||
|
} else if (rpc::isSendMaybeInFlight(result)) {
|
||||||
|
// Outcome unknown: the reply was lost after the request left. Do NOT present a
|
||||||
|
// plain failure (it invites a retry/double-send). Warn, point at History, and
|
||||||
|
// clear the form so a resend has to be retyped deliberately.
|
||||||
|
s_tx_status = std::string(TR("send_maybe_sent")) + "\n\n" + rpc::stripSendMaybeInFlight(result);
|
||||||
|
s_result_txid.clear();
|
||||||
|
s_status_success = false;
|
||||||
|
Notifications::instance().warning(TR("send_maybe_sent"), 15.0f);
|
||||||
|
s_to_address[0] = '\0';
|
||||||
|
s_amount = 0.0;
|
||||||
|
s_memo[0] = '\0';
|
||||||
|
s_send_max = false;
|
||||||
} else {
|
} else {
|
||||||
// Show a friendly cause in the toast; keep the raw backend text below it in
|
// Show a friendly cause in the toast; keep the raw backend text below it in
|
||||||
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
||||||
@@ -1010,10 +1049,11 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
}
|
}
|
||||||
bool can_send = app->isConnected() &&
|
bool can_send = app->isConnected() &&
|
||||||
!state.sync.syncing &&
|
!state.sync.syncing &&
|
||||||
|
state.sync.sendTipReady(app->isLiteBuild()) &&
|
||||||
is_valid_address &&
|
is_valid_address &&
|
||||||
s_amount > 0 &&
|
s_amount > 0 &&
|
||||||
s_from_address[0] != '\0' &&
|
s_from_address[0] != '\0' &&
|
||||||
total <= available &&
|
util::canAfford(s_amount, s_fee, available) &&
|
||||||
sourceSpendable &&
|
sourceSpendable &&
|
||||||
!s_sending;
|
!s_sending;
|
||||||
|
|
||||||
@@ -1033,6 +1073,12 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
float sendBtnW = width - cancelBtnW - btnGap;
|
float sendBtnW = width - cancelBtnW - btnGap;
|
||||||
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
|
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
|
||||||
|
|
||||||
|
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
|
||||||
|
if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
|
||||||
|
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Send button ----
|
// ---- Send button ----
|
||||||
if (!can_send) {
|
if (!can_send) {
|
||||||
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
|
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
|
||||||
@@ -1057,6 +1103,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
||||||
else if (state.sync.syncing)
|
else if (state.sync.syncing)
|
||||||
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||||
|
else if (!state.sync.sendTipReady(app->isLiteBuild()))
|
||||||
|
material::Tooltip("%s", TR("send_tooltip_no_tip"));
|
||||||
else if (s_from_address[0] == '\0')
|
else if (s_from_address[0] == '\0')
|
||||||
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
||||||
else if (is_mining_addr)
|
else if (is_mining_addr)
|
||||||
@@ -1065,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
||||||
else if (s_amount <= 0)
|
else if (s_amount <= 0)
|
||||||
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
||||||
else if (total > available)
|
else if (!util::canAfford(s_amount, s_fee, available))
|
||||||
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
||||||
else if (!sourceSpendable)
|
else if (!sourceSpendable)
|
||||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||||
@@ -1081,38 +1129,20 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
|
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
|
||||||
char clearId[64];
|
char clearId[64];
|
||||||
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("cancel"), suffix);
|
// This button never cancels an in-progress send (there is none here) — it clears the form. Relabel it
|
||||||
|
// honestly, disable it when the form is empty, and drop the redundant confirm (ClearFormWithUndo
|
||||||
|
// already offers a timed Undo). The "##Cancel" id suffix is kept so the widget id stays stable.
|
||||||
|
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("send_clear_form"), suffix);
|
||||||
|
ImGui::BeginDisabled(!FormHasData());
|
||||||
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
|
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
|
||||||
if (FormHasData()) {
|
|
||||||
s_clear_confirm_pending = true;
|
|
||||||
} else {
|
|
||||||
ClearFormWithUndo();
|
ClearFormWithUndo();
|
||||||
}
|
}
|
||||||
}
|
ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
material::Tooltip("%s", TR("send_clear_form_tip"));
|
||||||
ImGui::PopStyleVar(); // FrameBorderSize
|
ImGui::PopStyleVar(); // FrameBorderSize
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
|
|
||||||
// Clear confirmation popup
|
|
||||||
char confirmClearId[32];
|
|
||||||
snprintf(confirmClearId, sizeof(confirmClearId), "##ConfirmClear%s", suffix);
|
|
||||||
if (s_clear_confirm_pending) {
|
|
||||||
ImGui::OpenPopup(confirmClearId);
|
|
||||||
s_clear_confirm_pending = false;
|
|
||||||
}
|
|
||||||
if (ImGui::BeginPopup(confirmClearId)) {
|
|
||||||
ImGui::Text("%s", TR("send_clear_fields"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size * dp, 0), S.resolveFont("button"))) {
|
|
||||||
ClearFormWithUndo();
|
|
||||||
ImGui::CloseCurrentPopup();
|
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size * dp, 0), S.resolveFont("button"))) {
|
|
||||||
ImGui::CloseCurrentPopup();
|
|
||||||
}
|
|
||||||
ImGui::EndPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo button (shown briefly after clear)
|
// Undo button (shown briefly after clear)
|
||||||
if (s_undo_timestamp > 0.0) {
|
if (s_undo_timestamp > 0.0) {
|
||||||
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
|
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
|
||||||
@@ -1270,9 +1300,9 @@ void RenderSendTab(App* app)
|
|||||||
|
|
||||||
RenderSyncBanner(state);
|
RenderSyncBanner(state);
|
||||||
|
|
||||||
bool sendSyncing = state.sync.syncing && !state.sync.isSynced();
|
// The form stays EDITABLE while syncing so the user can pre-fill it; only the Review/Send button is
|
||||||
ImGui::BeginDisabled(sendSyncing);
|
// gated (can_send includes !syncing, and the confirm step re-checks live state), so nothing can be
|
||||||
|
// sent until the wallet is synced.
|
||||||
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
|
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
|
||||||
float hs = Layout::hScale(sendAvail.x);
|
float hs = Layout::hScale(sendAvail.x);
|
||||||
float vScale = Layout::vScale(sendAvail.y);
|
float vScale = Layout::vScale(sendAvail.y);
|
||||||
@@ -1329,7 +1359,6 @@ void RenderSendTab(App* app)
|
|||||||
// Zero balance CTA
|
// Zero balance CTA
|
||||||
if (RenderZeroBalanceCTA(app, dl, formW)) {
|
if (RenderZeroBalanceCTA(app, dl, formW)) {
|
||||||
ImGui::EndGroup();
|
ImGui::EndGroup();
|
||||||
ImGui::EndDisabled(); // sendSyncing guard
|
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1404,7 +1433,9 @@ void RenderSendTab(App* app)
|
|||||||
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
|
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
|
||||||
if (vt && ImGui::IsItemHovered())
|
if (vt && ImGui::IsItemHovered())
|
||||||
material::Tooltip("%s", TR("send_mining_addr_blocked"));
|
material::Tooltip("%s", TR("send_mining_addr_blocked"));
|
||||||
} else if (!checkPreview) {
|
} else if (!checkPreview && ImGui::GetActiveID() != ImGui::GetID("##ToAddr")) {
|
||||||
|
// Defer the "invalid" error until the field loses focus, so it doesn't flash red
|
||||||
|
// from the first keystroke while the user is still typing the address.
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
|
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
|
||||||
}
|
}
|
||||||
@@ -1517,6 +1548,7 @@ void RenderSendTab(App* app)
|
|||||||
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
||||||
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
||||||
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
||||||
{
|
{
|
||||||
@@ -1542,6 +1574,7 @@ void RenderSendTab(App* app)
|
|||||||
if (s_amount < 0) s_amount = 0;
|
if (s_amount < 0) s_amount = 0;
|
||||||
if (market.price_usd > 0)
|
if (market.price_usd > 0)
|
||||||
s_usd_amount = s_amount * market.price_usd;
|
s_usd_amount = s_amount * market.price_usd;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw USD equivalent inside the input field (right-aligned overlay)
|
// Draw USD equivalent inside the input field (right-aligned overlay)
|
||||||
if (market.price_usd > 0 && s_amount > 0) {
|
if (market.price_usd > 0 && s_amount > 0) {
|
||||||
@@ -1606,14 +1639,17 @@ void RenderSendTab(App* app)
|
|||||||
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
||||||
|
|
||||||
// Amount error
|
// Amount error
|
||||||
if (s_amount > 0 && s_amount + s_fee > available && available > 0) {
|
if (s_amount > 0 && !util::canAfford(s_amount, s_fee, available) && available > 0) {
|
||||||
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
||||||
Type().textColored(TypeStyle::Caption, Error(), buf);
|
Type().textColored(TypeStyle::Caption, Error(), buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- MEMO (shielded only) ----
|
// ---- MEMO (shielded only; shown disabled + explained for non-shielded recipients) ----
|
||||||
if (is_valid_z || s_to_address[0] == '\0') {
|
{
|
||||||
|
// Keep the memo visible (and the typed text intact) even for a transparent recipient, with an
|
||||||
|
// explainer, instead of silently hiding the field and dropping what the user wrote.
|
||||||
|
const bool memoDisabled = (s_to_address[0] != '\0' && !is_valid_z);
|
||||||
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
||||||
{
|
{
|
||||||
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
||||||
@@ -1628,8 +1664,10 @@ void RenderSendTab(App* app)
|
|||||||
|
|
||||||
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
|
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
|
||||||
ImGui::PushItemWidth(colW);
|
ImGui::PushItemWidth(colW);
|
||||||
|
if (memoDisabled) ImGui::BeginDisabled();
|
||||||
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
|
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
|
||||||
ImVec2(colW, memoInputH));
|
ImVec2(colW, memoInputH));
|
||||||
|
if (memoDisabled) ImGui::EndDisabled();
|
||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
|
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
|
||||||
@@ -1639,6 +1677,10 @@ void RenderSendTab(App* app)
|
|||||||
bool memoAtCap = memo_len + 1 >= memoMax;
|
bool memoAtCap = memo_len + 1 >= memoMax;
|
||||||
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
||||||
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
||||||
|
if (memoDisabled) {
|
||||||
|
const char* mk = s_memo[0] ? "send_memo_will_not_be_sent" : "send_memo_requires_shielded";
|
||||||
|
Type().textColored(TypeStyle::Caption, Warning(), TR(mk));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider before action buttons
|
// Divider before action buttons
|
||||||
@@ -1706,7 +1748,6 @@ void RenderSendTab(App* app)
|
|||||||
RenderRecentSends(state, formW, capFont, app);
|
RenderRecentSends(state, formW, capFont, app);
|
||||||
|
|
||||||
ImGui::EndGroup();
|
ImGui::EndGroup();
|
||||||
ImGui::EndDisabled(); // sendSyncing guard
|
|
||||||
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
|
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
|
||||||
// when content like "recent sends" elapsed-time text changes width each frame
|
// when content like "recent sends" elapsed-time text changes width each frame
|
||||||
float measuredH = ImGui::GetCursorPosY() - contentStartY;
|
float measuredH = ImGui::GetCursorPosY() - contentStartY;
|
||||||
|
|||||||
@@ -1,608 +0,0 @@
|
|||||||
// DragonX Wallet - ImGui Edition
|
|
||||||
// Copyright 2024-2026 The Hush Developers
|
|
||||||
// Released under the GPLv3
|
|
||||||
|
|
||||||
#include "settings_window.h"
|
|
||||||
#include "../../app.h"
|
|
||||||
#include "../../config/version.h"
|
|
||||||
#include "../../config/settings.h"
|
|
||||||
#include "../../util/i18n.h"
|
|
||||||
#include "../../util/platform.h"
|
|
||||||
#include "../../rpc/rpc_client.h"
|
|
||||||
#include "../theme.h"
|
|
||||||
#include "../schema/ui_schema.h"
|
|
||||||
#include "../schema/skin_manager.h"
|
|
||||||
#include "../notifications.h"
|
|
||||||
#include "../effects/imgui_acrylic.h"
|
|
||||||
#include "../material/draw_helpers.h"
|
|
||||||
#include "../../embedded/IconsMaterialDesign.h"
|
|
||||||
#include "imgui.h"
|
|
||||||
#include <vector>
|
|
||||||
#include <filesystem>
|
|
||||||
|
|
||||||
// Icon text for settings UI
|
|
||||||
#define ICON_CUSTOM_THEME ICON_MD_TUNE
|
|
||||||
#define ICON_REFRESH_THEMES ICON_MD_REFRESH
|
|
||||||
|
|
||||||
namespace dragonx {
|
|
||||||
namespace ui {
|
|
||||||
|
|
||||||
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
|
|
||||||
static std::string TrId(const char* tr_key, const char* id) {
|
|
||||||
std::string s = TR(tr_key);
|
|
||||||
s += "##";
|
|
||||||
s += id;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings state - these get loaded from Settings on window open
|
|
||||||
static bool s_initialized = false;
|
|
||||||
static int s_language_index = 0;
|
|
||||||
static bool s_save_ztxs = true;
|
|
||||||
static bool s_allow_custom_fees = false;
|
|
||||||
static bool s_auto_shield = false;
|
|
||||||
static bool s_fetch_prices = true;
|
|
||||||
static bool s_use_tor = false;
|
|
||||||
static char s_rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
|
|
||||||
static char s_rpc_port[16] = DRAGONX_DEFAULT_RPC_PORT;
|
|
||||||
static char s_rpc_user[64] = "";
|
|
||||||
static char s_rpc_password[64] = "";
|
|
||||||
static char s_tx_explorer[256] = "https://explorer.dragonx.is/tx/";
|
|
||||||
static char s_addr_explorer[256] = "https://explorer.dragonx.is/address/";
|
|
||||||
|
|
||||||
// Acrylic settings
|
|
||||||
static bool s_acrylic_enabled = true;
|
|
||||||
static float s_blur_amount = 1.5f; // 0.0=Off, continuous blur multiplier
|
|
||||||
static float s_noise_opacity = 0.5f;
|
|
||||||
static bool s_reduced_transparency = false; // Accessibility option
|
|
||||||
static bool s_gradient_background = false; // Gradient background mode
|
|
||||||
|
|
||||||
// Saved skin ID for cancel/revert
|
|
||||||
static std::string s_saved_skin_id;
|
|
||||||
|
|
||||||
// Load current settings into UI state
|
|
||||||
static void loadSettingsToUI(config::Settings* settings) {
|
|
||||||
if (!settings) return;
|
|
||||||
|
|
||||||
s_saved_skin_id = settings->getSkinId();
|
|
||||||
s_save_ztxs = settings->getSaveZtxs();
|
|
||||||
s_allow_custom_fees = settings->getAllowCustomFees();
|
|
||||||
s_auto_shield = settings->getAutoShield();
|
|
||||||
s_fetch_prices = settings->getFetchPrices();
|
|
||||||
s_use_tor = settings->getUseTor();
|
|
||||||
|
|
||||||
strncpy(s_tx_explorer, settings->getTxExplorerUrl().c_str(), sizeof(s_tx_explorer) - 1);
|
|
||||||
strncpy(s_addr_explorer, settings->getAddressExplorerUrl().c_str(), sizeof(s_addr_explorer) - 1);
|
|
||||||
|
|
||||||
// Set language index
|
|
||||||
auto& i18n = util::I18n::instance();
|
|
||||||
const auto& languages = i18n.getAvailableLanguages();
|
|
||||||
std::string current_lang = settings->getLanguage();
|
|
||||||
if (current_lang.empty()) current_lang = "en";
|
|
||||||
|
|
||||||
s_language_index = 0;
|
|
||||||
int idx = 0;
|
|
||||||
for (const auto& lang : languages) {
|
|
||||||
if (lang.first == current_lang) {
|
|
||||||
s_language_index = idx;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
s_initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save UI state to settings
|
|
||||||
static void saveSettingsFromUI(config::Settings* settings) {
|
|
||||||
if (!settings) return;
|
|
||||||
|
|
||||||
settings->setTheme(settings->getSkinId()); // Theme now synced with skin
|
|
||||||
settings->setSaveZtxs(s_save_ztxs);
|
|
||||||
settings->setAllowCustomFees(s_allow_custom_fees);
|
|
||||||
settings->setAutoShield(s_auto_shield);
|
|
||||||
settings->setFetchPrices(s_fetch_prices);
|
|
||||||
settings->setUseTor(s_use_tor);
|
|
||||||
settings->setTxExplorerUrl(s_tx_explorer);
|
|
||||||
settings->setAddressExplorerUrl(s_addr_explorer);
|
|
||||||
|
|
||||||
// Save language
|
|
||||||
auto& i18n = util::I18n::instance();
|
|
||||||
const auto& languages = i18n.getAvailableLanguages();
|
|
||||||
auto it = languages.begin();
|
|
||||||
std::advance(it, s_language_index);
|
|
||||||
if (it != languages.end()) {
|
|
||||||
settings->setLanguage(it->first);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save acrylic / visual effects settings
|
|
||||||
settings->setAcrylicEnabled(s_acrylic_enabled);
|
|
||||||
settings->setAcrylicQuality(s_blur_amount > 0.001f ? static_cast<int>(effects::AcrylicQuality::Low) : static_cast<int>(effects::AcrylicQuality::Off));
|
|
||||||
settings->setBlurMultiplier(s_blur_amount);
|
|
||||||
settings->setReducedTransparency(s_reduced_transparency);
|
|
||||||
settings->setNoiseOpacity(s_noise_opacity);
|
|
||||||
settings->setGradientBackground(s_gradient_background);
|
|
||||||
|
|
||||||
settings->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
void RenderSettingsWindow(App* app, bool* p_open)
|
|
||||||
{
|
|
||||||
// Load settings on first open
|
|
||||||
if (!s_initialized && app->settings()) {
|
|
||||||
loadSettingsToUI(app->settings());
|
|
||||||
// Initialize acrylic settings from current state
|
|
||||||
s_acrylic_enabled = effects::ImGuiAcrylic::IsEnabled();
|
|
||||||
s_blur_amount = effects::ImGuiAcrylic::GetBlurMultiplier();
|
|
||||||
s_noise_opacity = effects::ImGuiAcrylic::GetNoiseOpacity();
|
|
||||||
s_reduced_transparency = effects::ImGuiAcrylic::GetReducedTransparency();
|
|
||||||
s_gradient_background = schema::SkinManager::instance().isGradientMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& S = schema::UI();
|
|
||||||
auto win = S.window("dialogs.settings");
|
|
||||||
auto lbl = S.label("dialogs.settings", "label");
|
|
||||||
auto cmb = S.combo("dialogs.settings", "combo");
|
|
||||||
auto connLbl = S.label("dialogs.settings", "connection-label");
|
|
||||||
auto portInput = S.input("dialogs.settings", "port-input");
|
|
||||||
auto walletBtn = S.button("dialogs.settings", "wallet-button");
|
|
||||||
auto saveBtn = S.button("dialogs.settings", "save-button");
|
|
||||||
auto cancelBtn = S.button("dialogs.settings", "cancel-button");
|
|
||||||
|
|
||||||
if (!material::BeginOverlayDialog(TR("settings"), p_open, win.width, 0.94f)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui::BeginTabBar("SettingsTabs")) {
|
|
||||||
// General settings tab
|
|
||||||
if (ImGui::BeginTabItem(TR("general"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Skin/theme selection
|
|
||||||
ImGui::Text("%s", TR("theme"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
|
|
||||||
// Active skin combo (populated from SkinManager)
|
|
||||||
auto& skinMgr = schema::SkinManager::instance();
|
|
||||||
const auto& skins = skinMgr.available();
|
|
||||||
|
|
||||||
// Find active skin for preview text
|
|
||||||
std::string active_preview = "DragonX";
|
|
||||||
bool active_is_custom = false;
|
|
||||||
for (const auto& skin : skins) {
|
|
||||||
if (skin.id == skinMgr.activeSkinId()) {
|
|
||||||
active_preview = skin.name;
|
|
||||||
active_is_custom = !skin.bundled;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
if (ImGui::BeginCombo("##Theme", active_preview.c_str())) {
|
|
||||||
// Bundled themes header
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_builtin"));
|
|
||||||
ImGui::Separator();
|
|
||||||
for (size_t i = 0; i < skins.size(); i++) {
|
|
||||||
const auto& skin = skins[i];
|
|
||||||
if (!skin.bundled) continue;
|
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
|
||||||
|
|
||||||
if (ImGui::Selectable(skin.name.c_str(), is_selected)) {
|
|
||||||
skinMgr.setActiveSkin(skin.id);
|
|
||||||
if (app->settings()) {
|
|
||||||
app->settings()->setSkinId(skin.id);
|
|
||||||
app->settings()->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (is_selected) {
|
|
||||||
ImGui::SetItemDefaultFocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom themes (if any)
|
|
||||||
bool has_custom = false;
|
|
||||||
for (const auto& skin : skins) {
|
|
||||||
if (!skin.bundled) { has_custom = true; break; }
|
|
||||||
}
|
|
||||||
if (has_custom) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_custom"));
|
|
||||||
ImGui::Separator();
|
|
||||||
for (size_t i = 0; i < skins.size(); i++) {
|
|
||||||
const auto& skin = skins[i];
|
|
||||||
if (skin.bundled) continue;
|
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
|
||||||
|
|
||||||
if (!skin.valid) {
|
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
|
||||||
ImGui::BeginDisabled(true);
|
|
||||||
std::string label = skin.name + TR("swin_invalid_suffix");
|
|
||||||
ImGui::Selectable(label.c_str(), false);
|
|
||||||
ImGui::EndDisabled();
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
|
||||||
material::Tooltip("%s", skin.validationError.c_str());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
std::string label = skin.name;
|
|
||||||
if (!skin.author.empty()) {
|
|
||||||
label += " (" + skin.author + ")";
|
|
||||||
}
|
|
||||||
if (ImGui::Selectable(label.c_str(), is_selected)) {
|
|
||||||
skinMgr.setActiveSkin(skin.id);
|
|
||||||
if (app->settings()) {
|
|
||||||
app->settings()->setSkinId(skin.id);
|
|
||||||
app->settings()->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (is_selected) {
|
|
||||||
ImGui::SetItemDefaultFocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::EndCombo();
|
|
||||||
}
|
|
||||||
if (ImGui::IsItemHovered())
|
|
||||||
material::Tooltip("%s", TR("tt_theme_hotkey"));
|
|
||||||
|
|
||||||
// Show indicator if custom theme is active
|
|
||||||
if (active_is_custom) {
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::PushFont(material::Type().iconSmall());
|
|
||||||
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), ICON_CUSTOM_THEME);
|
|
||||||
ImGui::PopFont();
|
|
||||||
if (ImGui::IsItemHovered()) {
|
|
||||||
material::Tooltip("%s", TR("tt_custom_theme"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::PushFont(material::Type().iconSmall());
|
|
||||||
if (material::StyledButton(ICON_REFRESH_THEMES, ImVec2(0, 0))) {
|
|
||||||
skinMgr.refresh();
|
|
||||||
Notifications::instance().info(TR("swin_theme_list_refreshed"));
|
|
||||||
}
|
|
||||||
ImGui::PopFont();
|
|
||||||
if (ImGui::IsItemHovered()) {
|
|
||||||
material::Tooltip(TR("tt_scan_themes"),
|
|
||||||
schema::SkinManager::getUserSkinsDirectory().c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Language selection
|
|
||||||
ImGui::Text("%s", TR("language"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
auto& i18n = util::I18n::instance();
|
|
||||||
const auto& languages = i18n.getAvailableLanguages();
|
|
||||||
|
|
||||||
// Build language display names array
|
|
||||||
std::vector<const char*> lang_names;
|
|
||||||
lang_names.reserve(languages.size());
|
|
||||||
for (const auto& lang : languages) {
|
|
||||||
lang_names.push_back(lang.second.c_str()); // Display name
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
if (ImGui::Combo("##Language", &s_language_index, lang_names.data(), static_cast<int>(lang_names.size()))) {
|
|
||||||
// Get locale code from index
|
|
||||||
auto it = languages.begin();
|
|
||||||
std::advance(it, s_language_index);
|
|
||||||
i18n.loadLanguage(it->first);
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_language_note"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Acrylic Effects settings
|
|
||||||
ImGui::Text("%s", TR("settings_visual_effects"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_acrylic_level"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
{
|
|
||||||
char blur_fmt[16];
|
|
||||||
if (s_blur_amount < 0.01f)
|
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "Off");
|
|
||||||
else
|
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_blur_amount * 25.0f);
|
|
||||||
if (ImGui::SliderFloat("##AcrylicBlur", &s_blur_amount, 0.0f, 4.0f, blur_fmt,
|
|
||||||
ImGuiSliderFlags_AlwaysClamp)) {
|
|
||||||
if (s_blur_amount > 0.0f && s_blur_amount < 0.15f) s_blur_amount = 0.0f;
|
|
||||||
s_acrylic_enabled = (s_blur_amount > 0.001f);
|
|
||||||
effects::ImGuiAcrylic::ApplyBlurAmount(s_blur_amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("tt_blur"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Text("%s", TR("settings_noise_opacity"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
{
|
|
||||||
char noise_fmt[16];
|
|
||||||
if (s_noise_opacity < 0.01f)
|
|
||||||
snprintf(noise_fmt, sizeof(noise_fmt), "Off");
|
|
||||||
else
|
|
||||||
snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_noise_opacity * 100.0f);
|
|
||||||
if (ImGui::SliderFloat("##NoiseOpacity", &s_noise_opacity, 0.0f, 1.0f, noise_fmt,
|
|
||||||
ImGuiSliderFlags_AlwaysClamp)) {
|
|
||||||
effects::ImGuiAcrylic::SetNoiseOpacity(s_noise_opacity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("tt_noise"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Accessibility: Reduced transparency
|
|
||||||
if (ImGui::Checkbox(TrId("settings_reduce_transparency", "reduce_trans").c_str(), &s_reduced_transparency)) {
|
|
||||||
effects::ImGuiAcrylic::SetReducedTransparency(s_reduced_transparency);
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_solid_colors_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
if (ImGui::Checkbox(TrId("simple_background", "simple_bg").c_str(), &s_gradient_background)) {
|
|
||||||
schema::SkinManager::instance().setGradientMode(s_gradient_background);
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_gradient_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Privacy settings
|
|
||||||
ImGui::Text("%s", TR("settings_privacy"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Checkbox(TrId("settings_save_shielded_local", "save_ztx_w").c_str(), &s_save_ztxs);
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_save_shielded_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Checkbox(TrId("settings_auto_shield_funds", "auto_shld_w").c_str(), &s_auto_shield);
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_auto_shield_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
{
|
|
||||||
// Not implemented: disabled and never shown as "on", even for a stored use_tor=true.
|
|
||||||
bool torOff = false;
|
|
||||||
ImGui::BeginDisabled();
|
|
||||||
ImGui::Checkbox(TrId("settings_use_tor_network", "tor_w").c_str(), &torOff);
|
|
||||||
ImGui::EndDisabled();
|
|
||||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
|
||||||
const float wrapW = S.drawElement("components.settings-page", "tor-tooltip-wrap-width")
|
|
||||||
.sizeOr(420.0f) * Layout::dpiScale();
|
|
||||||
material::BeginTooltip();
|
|
||||||
ImGui::PushTextWrapPos(wrapW);
|
|
||||||
ImGui::TextUnformatted(TR(app && app->isLiteBuild() ? "tt_tor_unavailable_lite"
|
|
||||||
: "tt_tor_unavailable"));
|
|
||||||
ImGui::PopTextWrapPos();
|
|
||||||
material::EndTooltip();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Other settings
|
|
||||||
ImGui::Text("%s", TR("settings_other"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Checkbox(TrId("custom_fees", "fees_w").c_str(), &s_allow_custom_fees);
|
|
||||||
ImGui::Checkbox(TrId("fetch_prices", "prices_w").c_str(), &s_fetch_prices);
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection settings tab
|
|
||||||
if (ImGui::BeginTabItem(TR("settings_connection"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_rpc_connection"));
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_configure_rpc"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_host"), connLbl.position, "##RPCHost",
|
|
||||||
s_rpc_host, sizeof(s_rpc_host), cmb.width);
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_port"), connLbl.position, "##RPCPort",
|
|
||||||
s_rpc_port, sizeof(s_rpc_port), portInput.width);
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_user"), connLbl.position, "##RPCUser",
|
|
||||||
s_rpc_user, sizeof(s_rpc_user), cmb.width);
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_pass"), connLbl.position, "##RPCPassword",
|
|
||||||
s_rpc_password, sizeof(s_rpc_password), cmb.width, nullptr,
|
|
||||||
ImGuiInputTextFlags_Password);
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_rpc_note"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
if (material::StyledButton(TR("test_connection"), ImVec2(0,0), S.resolveFont("button"))) {
|
|
||||||
if (app->rpc()) {
|
|
||||||
rpc::RPCClient::TraceScope trace("Settings / Test connection");
|
|
||||||
app->rpc()->getInfo([](const nlohmann::json& result, const std::string& error) {
|
|
||||||
if (error.empty()) {
|
|
||||||
std::string version = result.value("version", "unknown");
|
|
||||||
std::string msg = std::string(TR("swin_connection_successful")) + version;
|
|
||||||
Notifications::instance().success(msg);
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(std::string(TR("swin_connection_failed")) + error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(TR("swin_rpc_client_not_initialized"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wallet tab
|
|
||||||
if (ImGui::BeginTabItem(TR("wallet"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_wallet_maintenance"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
if (material::StyledButton(TR("rescan"), ImVec2(walletBtn.width, 0), S.resolveFont(walletBtn.font))) {
|
|
||||||
if (app->rpc()) {
|
|
||||||
// Start rescan from block 0
|
|
||||||
app->rpc()->rescanBlockchain(0, [](const nlohmann::json& result, const std::string& error) {
|
|
||||||
if (error.empty()) {
|
|
||||||
int start = result.value("start_height", 0);
|
|
||||||
int end = result.value("stop_height", 0);
|
|
||||||
std::string msg = std::string(TR("swin_rescan_started_from_block")) + std::to_string(start) +
|
|
||||||
TR("swin_rescan_to") + std::to_string(end);
|
|
||||||
Notifications::instance().success(msg);
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(std::string(TR("swin_rescan_failed")) + error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(TR("swin_rpc_client_not_initialized"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_rescan_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
static bool s_confirm_clear_ztx = false;
|
|
||||||
if (material::StyledButton(TR("settings_clear_ztx_long"), ImVec2(walletBtn.width, 0), S.resolveFont(walletBtn.font))) {
|
|
||||||
s_confirm_clear_ztx = true;
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_clear_ztx_desc"));
|
|
||||||
|
|
||||||
// Confirmation dialog
|
|
||||||
if (s_confirm_clear_ztx) {
|
|
||||||
// Distinct idSuffix: this confirm renders nested inside (and the same frame as) the parent
|
|
||||||
// settings dialog, so it must not share the default ##OverlayDialogContent key — otherwise it
|
|
||||||
// inherits the parent's OverlayCardState (incl. the sticky overflow flag) and collides on the
|
|
||||||
// child window id.
|
|
||||||
if (material::BeginOverlayDialog(TR("confirm_clear_ztx_title"), &s_confirm_clear_ztx, 480.0f,
|
|
||||||
0.94f, 0.85f, "settings_clearztx")) {
|
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.6f, 0.0f, 1.0f));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning1"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning2"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
bool doCancel = false, doConfirm = false;
|
|
||||||
material::DialogConfirmFooter(TR("cancel"), TrId("clear_anyway", "clear_ztx_w").c_str(),
|
|
||||||
true, doCancel, doConfirm);
|
|
||||||
if (doCancel) {
|
|
||||||
s_confirm_clear_ztx = false;
|
|
||||||
}
|
|
||||||
if (doConfirm) {
|
|
||||||
std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json";
|
|
||||||
if (util::Platform::deleteFile(ztx_file)) {
|
|
||||||
Notifications::instance().success(TR("swin_ztx_history_cleared"));
|
|
||||||
} else {
|
|
||||||
Notifications::instance().info(TR("swin_no_history_file_found"));
|
|
||||||
}
|
|
||||||
s_confirm_clear_ztx = false;
|
|
||||||
}
|
|
||||||
material::EndOverlayDialog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_wallet_info"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Get actual wallet size
|
|
||||||
std::string wallet_path = util::Platform::getDragonXDataDir() + "wallet.dat";
|
|
||||||
uint64_t wallet_size = util::Platform::getFileSize(wallet_path);
|
|
||||||
if (wallet_size > 0) {
|
|
||||||
std::string size_str = util::Platform::formatFileSize(wallet_size);
|
|
||||||
ImGui::Text(TR("settings_wallet_file_size"), size_str.c_str());
|
|
||||||
} else {
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_wallet_not_found"));
|
|
||||||
}
|
|
||||||
ImGui::Text(TR("settings_wallet_location"), wallet_path.c_str());
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explorer tab
|
|
||||||
if (ImGui::BeginTabItem(TR("explorer"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_block_explorer_urls"));
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_configure_explorer"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("transaction_url"));
|
|
||||||
ImGui::SetNextItemWidth(-1);
|
|
||||||
ImGui::InputText("##TxExplorer", s_tx_explorer, sizeof(s_tx_explorer));
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("address_url"));
|
|
||||||
ImGui::SetNextItemWidth(-1);
|
|
||||||
ImGui::InputText("##AddrExplorer", s_addr_explorer, sizeof(s_addr_explorer));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_explorer_hint"));
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::EndTabBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Save/Cancel buttons
|
|
||||||
if (material::StyledButton(TR("save"), ImVec2(saveBtn.width, 0), S.resolveFont(saveBtn.font))) {
|
|
||||||
saveSettingsFromUI(app->settings());
|
|
||||||
Notifications::instance().success(TR("swin_settings_saved"));
|
|
||||||
*p_open = false;
|
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (material::StyledButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
|
||||||
// Reload settings to revert changes
|
|
||||||
loadSettingsToUI(app->settings());
|
|
||||||
// Revert skin to what was active when settings opened
|
|
||||||
if (!s_saved_skin_id.empty()) {
|
|
||||||
schema::SkinManager::instance().setActiveSkin(s_saved_skin_id);
|
|
||||||
if (app->settings()) {
|
|
||||||
app->settings()->setSkinId(s_saved_skin_id);
|
|
||||||
app->settings()->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*p_open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
material::EndOverlayDialog();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace ui
|
|
||||||
} // namespace dragonx
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// DragonX Wallet - ImGui Edition
|
|
||||||
// Copyright 2024-2026 The Hush Developers
|
|
||||||
// Released under the GPLv3
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
namespace dragonx {
|
|
||||||
class App;
|
|
||||||
|
|
||||||
namespace ui {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Render the Settings window
|
|
||||||
* Modal dialog for application settings
|
|
||||||
*/
|
|
||||||
void RenderSettingsWindow(App* app, bool* p_open);
|
|
||||||
|
|
||||||
} // namespace ui
|
|
||||||
} // namespace dragonx
|
|
||||||
@@ -41,6 +41,8 @@ static bool s_advanced = false; // Advanced (fee + batch size)
|
|||||||
static bool s_confirm = false; // inline "confirm before moving funds" phase
|
static bool s_confirm = false; // inline "confirm before moving funds" phase
|
||||||
static bool s_operation_pending = false;
|
static bool s_operation_pending = false;
|
||||||
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
|
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
|
||||||
|
enum class OpOutcome { None, Success, Failed, Uncertain };
|
||||||
|
static OpOutcome s_op_outcome = OpOutcome::None; // tags the terminal status for colour (display only)
|
||||||
static std::string s_operation_id;
|
static std::string s_operation_id;
|
||||||
static std::string s_status_message;
|
static std::string s_status_message;
|
||||||
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
||||||
@@ -68,6 +70,7 @@ static void resetTransient()
|
|||||||
++s_poll_session;
|
++s_poll_session;
|
||||||
s_operation_pending = false;
|
s_operation_pending = false;
|
||||||
s_op_terminal = false;
|
s_op_terminal = false;
|
||||||
|
s_op_outcome = OpOutcome::None;
|
||||||
s_confirm = false;
|
s_confirm = false;
|
||||||
s_status_message.clear();
|
s_status_message.clear();
|
||||||
s_operation_id.clear();
|
s_operation_id.clear();
|
||||||
@@ -201,6 +204,25 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
|
|||||||
|
|
||||||
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
||||||
|
|
||||||
|
// Shield-coinbase source helpers. "*" = all spendable mining (transparent) addresses; else one address.
|
||||||
|
// Balances are DISPLAY totals (may include immature/non-coinbase), so the UI wording says "up to".
|
||||||
|
static double miningBalance(const WalletState& st, const char* from) {
|
||||||
|
if (std::strcmp(from, "*") == 0) {
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const auto& a : st.t_addresses) if (a.isSpendable()) sum += a.balance;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
const int i = addressIndexByString(st.t_addresses, from);
|
||||||
|
return i >= 0 ? st.t_addresses[i].balance : 0.0;
|
||||||
|
}
|
||||||
|
static int miningAddrCount(const WalletState& st, const char* from) {
|
||||||
|
if (std::strcmp(from, "*") == 0) {
|
||||||
|
int n = 0; for (const auto& a : st.t_addresses) if (a.isSpendable() && a.balance > 0.0) ++n; return n;
|
||||||
|
}
|
||||||
|
const int i = addressIndexByString(st.t_addresses, from);
|
||||||
|
return (i >= 0 && st.t_addresses[i].balance > 0.0) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
|
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
|
||||||
static void wrappedCaption(ImU32 color, const char* text)
|
static void wrappedCaption(ImU32 color, const char* text)
|
||||||
{
|
{
|
||||||
@@ -232,6 +254,7 @@ static void submitOperation(App* app)
|
|||||||
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
|
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
|
||||||
s_operation_pending = true;
|
s_operation_pending = true;
|
||||||
s_op_terminal = false;
|
s_op_terminal = false;
|
||||||
|
s_op_outcome = OpOutcome::None;
|
||||||
s_status_message = TR("shield_submitting");
|
s_status_message = TR("shield_submitting");
|
||||||
s_last_poll = ImGui::GetTime();
|
s_last_poll = ImGui::GetTime();
|
||||||
|
|
||||||
@@ -253,8 +276,9 @@ static void submitOperation(App* app)
|
|||||||
app->trackOperation(s_operation_id);
|
app->trackOperation(s_operation_id);
|
||||||
} else {
|
} else {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
s_op_outcome = OpOutcome::Failed;
|
||||||
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
|
s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error);
|
||||||
|
Notifications::instance().error(std::string(TR("shield_send_failed")) + util::friendlyRpcError(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -309,13 +333,15 @@ static void submitOperation(App* app)
|
|||||||
app->trackOperation(s_operation_id);
|
app->trackOperation(s_operation_id);
|
||||||
} else if (uncertain) {
|
} else if (uncertain) {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Uncertain;
|
||||||
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
|
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
|
||||||
s_status_message = TR("merge_submit_uncertain");
|
s_status_message = TR("merge_submit_uncertain");
|
||||||
Notifications::instance().warning(TR("merge_submit_uncertain"));
|
Notifications::instance().warning(TR("merge_submit_uncertain"));
|
||||||
} else {
|
} else {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
s_op_outcome = OpOutcome::Failed;
|
||||||
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
|
s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error);
|
||||||
|
Notifications::instance().error(std::string(TR("merge_send_failed")) + util::friendlyRpcError(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -340,6 +366,7 @@ static void submitPerAddress(App* app)
|
|||||||
s_confirm = false;
|
s_confirm = false;
|
||||||
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
|
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
|
||||||
s_op_terminal = false;
|
s_op_terminal = false;
|
||||||
|
s_op_outcome = OpOutcome::None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +411,7 @@ static void pollOperation(App* app)
|
|||||||
// client popped it with z_getoperationresult). It registers an op before returning its id,
|
// client popped it with z_getoperationresult). It registers an op before returning its id,
|
||||||
// so this is no race. The outcome can't be confirmed; stop polling.
|
// so this is no race. The outcome can't be confirmed; stop polling.
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Uncertain;
|
||||||
s_status_message = TR("send_status_unconfirmed");
|
s_status_message = TR("send_status_unconfirmed");
|
||||||
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
||||||
return;
|
return;
|
||||||
@@ -393,6 +421,7 @@ static void pollOperation(App* app)
|
|||||||
? op["status"].get<std::string>() : std::string();
|
? op["status"].get<std::string>() : std::string();
|
||||||
if (status == "success") {
|
if (status == "success") {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Success;
|
||||||
s_status_message = TR("shield_completed");
|
s_status_message = TR("shield_completed");
|
||||||
Notifications::instance().success(TR("shield_merge_done"));
|
Notifications::instance().success(TR("shield_merge_done"));
|
||||||
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||||
@@ -401,6 +430,7 @@ static void pollOperation(App* app)
|
|||||||
op["error"]["message"].is_string())
|
op["error"]["message"].is_string())
|
||||||
msg = op["error"]["message"].get<std::string>();
|
msg = op["error"]["message"].get<std::string>();
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Failed;
|
||||||
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
||||||
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
|
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
|
||||||
// one failure toast, whether this dialog is open or not.
|
// one failure toast, whether this dialog is open or not.
|
||||||
@@ -556,8 +586,32 @@ void ShieldDialog::render(App* app)
|
|||||||
|
|
||||||
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
|
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
|
||||||
if (!isMerge && !opInFlight) {
|
if (!isMerge && !opInFlight) {
|
||||||
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
|
// Pick the mining (transparent) address to shield from a dropdown with balances, instead of a
|
||||||
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
|
// free-text box with a "*" wildcard. "*" shields all of them.
|
||||||
|
ImGui::TextUnformatted(TR("shield_from_address"));
|
||||||
|
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
|
||||||
|
const std::string fromLbl = allFrom ? std::string(TR("shield_all_mining_addrs"))
|
||||||
|
: (app->getAddressLabel(s_from_address).empty() ? shortAddr(s_from_address)
|
||||||
|
: app->getAddressLabel(s_from_address));
|
||||||
|
ImGui::SetNextItemWidth(-1);
|
||||||
|
if (ImGui::BeginCombo("##FromAddr", fromLbl.c_str())) {
|
||||||
|
if (ImGui::Selectable(TR("shield_all_mining_addrs"), allFrom))
|
||||||
|
strncpy(s_from_address, "*", sizeof(s_from_address));
|
||||||
|
for (const auto& a : state.t_addresses) {
|
||||||
|
if (!a.isSpendable()) continue;
|
||||||
|
const std::string lbl = app->getAddressLabel(a.address);
|
||||||
|
char item[192];
|
||||||
|
std::snprintf(item, sizeof(item), "%s (%s DRGX)",
|
||||||
|
lbl.empty() ? shortAddr(a.address).c_str() : lbl.c_str(),
|
||||||
|
fmtAmt(a.balance).c_str());
|
||||||
|
const bool sel = std::strcmp(s_from_address, a.address.c_str()) == 0;
|
||||||
|
if (ImGui::Selectable(item, sel)) {
|
||||||
|
strncpy(s_from_address, a.address.c_str(), sizeof(s_from_address) - 1);
|
||||||
|
s_from_address[sizeof(s_from_address) - 1] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndCombo();
|
||||||
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,10 +800,18 @@ void ShieldDialog::render(App* app)
|
|||||||
|
|
||||||
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
||||||
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
|
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
|
||||||
if (s_operation_pending && !s_op_terminal)
|
const bool inFlight = s_operation_pending && !s_op_terminal;
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
const ImU32 stCol = inFlight ? material::Warning()
|
||||||
|
: s_op_outcome == OpOutcome::Success ? material::Success()
|
||||||
|
: s_op_outcome == OpOutcome::Failed ? material::Error()
|
||||||
|
: s_op_outcome == OpOutcome::Uncertain ? material::Warning()
|
||||||
|
: material::OnSurfaceMedium();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(stCol));
|
||||||
|
if (inFlight)
|
||||||
|
ImGui::Text("%s%s", s_status_message.c_str(), material::LoadingDots()); // spinner while scanning
|
||||||
else
|
else
|
||||||
ImGui::TextWrapped("%s", s_status_message.c_str());
|
ImGui::TextWrapped("%s", s_status_message.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,9 +876,24 @@ void ShieldDialog::render(App* app)
|
|||||||
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
|
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
|
||||||
ImGui::TextWrapped("%s", cb);
|
ImGui::TextWrapped("%s", cb);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
} else if (s_confirm && !isMerge) {
|
||||||
|
// Shield-coinbase preview: amount / address count / fee / destination (no amount/count was shown before).
|
||||||
|
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
|
||||||
|
const std::string toLbl = app->getAddressLabel(s_to_address);
|
||||||
|
char cb[360];
|
||||||
|
std::snprintf(cb, sizeof(cb), TR("shield_confirm_fmt"),
|
||||||
|
fmtAmt(miningBalance(state, s_from_address)).c_str(),
|
||||||
|
allFrom ? miningAddrCount(state, s_from_address) : 1,
|
||||||
|
s_utxo_limit,
|
||||||
|
toLbl.empty() ? shortAddr(s_to_address).c_str() : toLbl.c_str(),
|
||||||
|
fmtAmt(s_fee).c_str());
|
||||||
|
ImGui::TextWrapped("%s", cb);
|
||||||
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool can_submit = haveDest && connected && !syncing;
|
bool can_submit = haveDest && connected && !syncing;
|
||||||
|
// Block a shield from an empty source or one with no funds (the free-text box allowed both).
|
||||||
|
if (!isMerge && (s_from_address[0] == '\0' || miningBalance(state, s_from_address) <= 0.0)) can_submit = false;
|
||||||
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
|
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
|
||||||
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
||||||
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
||||||
|
|||||||
@@ -58,16 +58,16 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
ImVec4 type_color;
|
ImVec4 type_color;
|
||||||
std::string type_display;
|
std::string type_display;
|
||||||
if (tx.type == "receive") {
|
if (tx.type == "receive") {
|
||||||
type_color = ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
|
type_color = material::SuccessVec4();
|
||||||
type_display = TR("tx_received");
|
type_display = TR("tx_received");
|
||||||
} else if (tx.type == "send") {
|
} else if (tx.type == "send") {
|
||||||
type_color = ImVec4(0.8f, 0.3f, 0.3f, 1.0f);
|
type_color = material::ErrorVec4();
|
||||||
type_display = TR("tx_sent");
|
type_display = TR("tx_sent");
|
||||||
} else if (tx.type == "generate" || tx.type == "mined") {
|
} else if (tx.type == "generate" || tx.type == "mined") {
|
||||||
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
||||||
type_display = TR("tx_mined");
|
type_display = TR("tx_mined");
|
||||||
} else if (tx.type == "immature") {
|
} else if (tx.type == "immature") {
|
||||||
type_color = ImVec4(0.8f, 0.8f, 0.3f, 1.0f);
|
type_color = material::WarningVec4();
|
||||||
type_display = TR("tx_immature");
|
type_display = TR("tx_immature");
|
||||||
} else {
|
} else {
|
||||||
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
||||||
@@ -79,11 +79,11 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
|
|
||||||
// Confirmations badge
|
// Confirmations badge
|
||||||
if (tx.confirmations == 0) {
|
if (tx.confirmations == 0) {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("pending"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("pending"));
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations);
|
ImGui::TextColored(material::WarningVec4(), TR("tx_confirmations"), tx.confirmations);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations);
|
ImGui::TextColored(material::SuccessVec4(), TR("tx_confirmations"), tx.confirmations);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -94,9 +94,9 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
ImGui::Text("%s", TR("amount_label"));
|
ImGui::Text("%s", TR("amount_label"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (tx.amount >= 0) {
|
if (tx.amount >= 0) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "+%.8f DRGX", tx.amount);
|
ImGui::TextColored(material::SuccessVec4(), "+%.8f DRGX", tx.amount);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%.8f DRGX", tx.amount);
|
ImGui::TextColored(material::ErrorVec4(), "%.8f DRGX", tx.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD equivalent if price available
|
// USD equivalent if price available
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Released under the GPLv3
|
// Released under the GPLv3
|
||||||
|
|
||||||
#include "transactions_tab.h"
|
#include "transactions_tab.h"
|
||||||
|
#include "../notifications.h"
|
||||||
#include "transaction_details_dialog.h"
|
#include "transaction_details_dialog.h"
|
||||||
#include "export_transactions_dialog.h"
|
#include "export_transactions_dialog.h"
|
||||||
#include "transactions_tab_helpers.h"
|
#include "transactions_tab_helpers.h"
|
||||||
@@ -36,6 +37,11 @@ namespace ui {
|
|||||||
|
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
|
// Latest History-tab filter snapshot, published from the filtered-list rebuild below. Used by the
|
||||||
|
// transactions CSV export to offer "export only the current filter".
|
||||||
|
static TxFilterSnapshot s_filter_snapshot;
|
||||||
|
const TxFilterSnapshot& GetTransactionsFilterSnapshot() { return s_filter_snapshot; }
|
||||||
|
|
||||||
static std::string TrId(const char* key, const char* id) {
|
static std::string TrId(const char* key, const char* id) {
|
||||||
return std::string(TR(key)) + "##" + id;
|
return std::string(TR(key)) + "##" + id;
|
||||||
}
|
}
|
||||||
@@ -213,10 +219,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
||||||
// (a sort-combo change re-sorts without regrouping).
|
// (a sort-combo change re-sorts without regrouping).
|
||||||
static history::TxidGroups s_groups;
|
static history::TxidGroups s_groups;
|
||||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
|
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
|
||||||
if (s_groups_key != contentKey) {
|
const std::uint64_t structKey = state.tx_structure_revision; // ignores pure confirmation-count bumps
|
||||||
|
if (s_groups_key != structKey) {
|
||||||
s_groups = history::groupByTxid(state.transactions);
|
s_groups = history::groupByTxid(state.transactions);
|
||||||
s_groups_key = contentKey;
|
s_groups_key = structKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||||
@@ -409,13 +416,14 @@ void RenderTransactionsTab(App* app)
|
|||||||
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
||||||
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
||||||
static std::vector<DisplayTx> s_display_cache;
|
static std::vector<DisplayTx> s_display_cache;
|
||||||
static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from
|
static std::uint64_t s_display_cache_key = 0; // tx_structure_revision the cache was built from
|
||||||
|
static std::uint64_t s_display_conf_rev = 0; // tx_revision the cached confirmations were last synced to
|
||||||
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
||||||
static bool s_display_cache_valid = false;
|
static bool s_display_cache_valid = false;
|
||||||
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
||||||
|
|
||||||
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
||||||
if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
if (!s_display_cache_valid || structKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
||||||
++s_display_gen;
|
++s_display_gen;
|
||||||
s_display_cache.clear();
|
s_display_cache.clear();
|
||||||
std::vector<DisplayTx>& display_txns = s_display_cache;
|
std::vector<DisplayTx>& display_txns = s_display_cache;
|
||||||
@@ -518,9 +526,27 @@ void RenderTransactionsTab(App* app)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
s_display_cache_key = contentKey;
|
s_display_cache_key = structKey;
|
||||||
|
s_display_conf_rev = contentKey; // freshly built from the current list
|
||||||
s_display_cache_sort = s_sort_mode;
|
s_display_cache_sort = s_sort_mode;
|
||||||
s_display_cache_valid = true;
|
s_display_cache_valid = true;
|
||||||
|
} else if (s_display_conf_rev != contentKey) {
|
||||||
|
// Confirmation-count-only delta (setTransactionsIfChanged kept tx_structure_revision): the merged rows, their
|
||||||
|
// order and the indices into state.transactions are unchanged, so refresh just the count (same rule as the
|
||||||
|
// build above: a shield shows the larger of its two legs). Sort order cannot change: mode 0 only reads
|
||||||
|
// conf==0, which the layout check holds constant, and the other modes ignore confirmations.
|
||||||
|
for (DisplayTx& dtx : s_display_cache) {
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
dtx.confirmations = std::max(state.transactions[dtx.send_idx].confirmations,
|
||||||
|
state.transactions[dtx.recv_idx].confirmations);
|
||||||
|
} else {
|
||||||
|
dtx.confirmations = state.transactions[dtx.orig_idx].confirmations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The filtered-index cache keys on s_display_gen (unchanged, correct: filters ignore confirmations) and
|
||||||
|
// the snapshot's indices remain valid; only re-stamp it so 'export current filter' stays usable.
|
||||||
|
if (s_filter_snapshot.valid) s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_display_conf_rev = contentKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
||||||
@@ -565,6 +591,35 @@ void RenderTransactionsTab(App* app)
|
|||||||
s_filtered_search = search_str;
|
s_filtered_search = search_str;
|
||||||
s_filtered_chat_rev = chatRev;
|
s_filtered_chat_rev = chatRev;
|
||||||
s_filtered_valid = true;
|
s_filtered_valid = true;
|
||||||
|
|
||||||
|
// Publish a snapshot of the visible set for "export current filter". A merged autoshield row
|
||||||
|
// contributes BOTH original legs, so the CSV stays a per-leg record like an unfiltered export.
|
||||||
|
s_filter_snapshot.indices.clear();
|
||||||
|
s_filter_snapshot.indices.reserve(s_filtered_cache.size() * 2);
|
||||||
|
for (size_t fi : s_filtered_cache) {
|
||||||
|
const auto& dtx = display_txns[fi];
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
if (dtx.send_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.send_idx));
|
||||||
|
if (dtx.recv_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.recv_idx));
|
||||||
|
} else if (dtx.orig_idx >= 0) {
|
||||||
|
s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.orig_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(s_filter_snapshot.indices.begin(), s_filter_snapshot.indices.end());
|
||||||
|
s_filter_snapshot.active = (type_filter != 0 || !search_str.empty());
|
||||||
|
s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_filter_snapshot.valid = true;
|
||||||
|
{
|
||||||
|
const char* typeNames[] = { "", TR("sent_filter"), TR("received_filter"),
|
||||||
|
TR("mined_filter"), TR("chat_filter") };
|
||||||
|
std::string desc = (type_filter >= 1 && type_filter <= 4) ? typeNames[type_filter] : "";
|
||||||
|
if (!search_str.empty()) {
|
||||||
|
if (!desc.empty()) desc += ", ";
|
||||||
|
desc += "\"" + search_str + "\"";
|
||||||
|
}
|
||||||
|
if (desc.empty()) desc = TR("all_filter");
|
||||||
|
s_filter_snapshot.description = desc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const std::vector<size_t>& filtered_indices = s_filtered_cache;
|
const std::vector<size_t>& filtered_indices = s_filtered_cache;
|
||||||
filtered_count = static_cast<int>(filtered_indices.size());
|
filtered_count = static_cast<int>(filtered_indices.size());
|
||||||
@@ -800,7 +855,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
{
|
{
|
||||||
const char* statusStr;
|
const char* statusStr;
|
||||||
ImU32 statusCol;
|
ImU32 statusCol;
|
||||||
if (tx.confirmations == 0) {
|
const int64_t nowSec = static_cast<int64_t>(std::time(nullptr));
|
||||||
|
if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out
|
||||||
|
statusStr = TR("tx_dropped"); statusCol = Error();
|
||||||
|
} else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) {
|
||||||
|
statusStr = TR("tx_stale_pending"); statusCol = Warning();
|
||||||
|
} else if (tx.confirmations == 0) {
|
||||||
statusStr = TR("pending"); statusCol = Warning();
|
statusStr = TR("pending"); statusCol = Warning();
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
||||||
@@ -871,9 +931,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
||||||
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
||||||
ImGui::SetClipboardText(tx.address.c_str());
|
ImGui::SetClipboardText(tx.address.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("copy_txid"))) {
|
if (ImGui::MenuItem(TR("copy_txid"))) {
|
||||||
ImGui::SetClipboardText(tx.txid.c_str());
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
||||||
@@ -916,7 +978,10 @@ void RenderTransactionsTab(App* app)
|
|||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
||||||
ImGui::TextWrapped("%s", tx.txid.c_str());
|
ImGui::TextWrapped("%s", tx.txid.c_str());
|
||||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str());
|
if (ImGui::IsItemClicked()) {
|
||||||
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
// Memo
|
// Memo
|
||||||
@@ -938,10 +1003,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
||||||
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(tx.txid.c_str());
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(tx.address.c_str());
|
ImGui::SetClipboardText(tx.address.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
|
|||||||
@@ -5,12 +5,34 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
class App;
|
class App;
|
||||||
|
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Snapshot of what the History tab currently shows, for "export current filter".
|
||||||
|
*
|
||||||
|
* `indices` are positions into WalletState::transactions of every leg currently visible (a merged
|
||||||
|
* autoshield row contributes both its send and receive legs), sorted ascending. Published only when the
|
||||||
|
* filtered list is (re)built, so it reflects the last state the History tab rendered. `txRevision` lets a
|
||||||
|
* consumer reject a snapshot that predates the current tx list.
|
||||||
|
*/
|
||||||
|
struct TxFilterSnapshot {
|
||||||
|
bool valid = false;
|
||||||
|
bool active = false; // a type filter and/or search text is applied
|
||||||
|
std::uint64_t txRevision = 0;
|
||||||
|
std::string description; // human label of the active filter
|
||||||
|
std::vector<std::size_t> indices;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The latest History-tab filter snapshot (see TxFilterSnapshot).
|
||||||
|
const TxFilterSnapshot& GetTransactionsFilterSnapshot();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Render the Transactions tab
|
* @brief Render the Transactions tab
|
||||||
* Shows transaction history
|
* Shows transaction history
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Validate button
|
// Validate button
|
||||||
bool can_validate = strlen(s_address_input) > 0 && !s_validating && app->isConnected();
|
// Classification + checksum are LOCAL, so allow validating offline (the is-mine check needs the node).
|
||||||
|
bool can_validate = strlen(s_address_input) > 0 && !s_validating;
|
||||||
|
|
||||||
if (!can_validate) {
|
if (!can_validate) {
|
||||||
ImGui::BeginDisabled();
|
ImGui::BeginDisabled();
|
||||||
@@ -123,8 +124,9 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
s_address_type.clear();
|
s_address_type.clear();
|
||||||
s_validated = true;
|
s_validated = true;
|
||||||
s_validating = false;
|
s_validating = false;
|
||||||
} else if (app->isLiteBuild()) {
|
} else if (app->isLiteBuild() || !app->isConnected()) {
|
||||||
// Lite has no node to ask: the checksum verdict is the answer, ownership is unknown (hidden).
|
// No node to ask (lite, or full-node offline): the checksum verdict is the answer; ownership
|
||||||
|
// is unknown. The result block shows why.
|
||||||
s_is_valid = true;
|
s_is_valid = true;
|
||||||
s_is_mine = false;
|
s_is_mine = false;
|
||||||
s_ownership_known = false;
|
s_ownership_known = false;
|
||||||
@@ -181,7 +183,7 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
|
|
||||||
if (s_validating) {
|
if (s_validating) {
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("validating"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("validating"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -192,15 +194,15 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
if (!s_error_message.empty()) {
|
if (!s_error_message.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error_message.c_str());
|
ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error_message.c_str());
|
||||||
} else {
|
} else {
|
||||||
// Valid/Invalid indicator
|
// Valid/Invalid indicator
|
||||||
ImGui::Text("%s", TR("validate_status"));
|
ImGui::Text("%s", TR("validate_status"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (s_is_valid) {
|
if (s_is_valid) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_valid"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_valid"));
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("validate_invalid"));
|
ImGui::TextColored(material::ErrorVec4(), "%s", TR("validate_invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s_is_valid) {
|
if (s_is_valid) {
|
||||||
@@ -209,16 +211,18 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
ImGui::Text("%s", s_address_type.c_str());
|
ImGui::Text("%s", s_address_type.c_str());
|
||||||
|
|
||||||
// Is mine? (not shown when no node was asked - lite)
|
// Is mine? When no node was asked (lite / offline) say why ownership is unknown rather
|
||||||
if (s_ownership_known) {
|
// than silently omitting it.
|
||||||
ImGui::Text("%s", TR("validate_ownership"));
|
ImGui::Text("%s", TR("validate_ownership"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (s_is_mine) {
|
if (!s_ownership_known) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine"));
|
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
||||||
|
: "validate_ownership_unknown_offline"));
|
||||||
|
} else if (s_is_mine) {
|
||||||
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_is_mine"));
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (s_is_mining) { // valid, but not for payments
|
if (s_is_mining) { // valid, but not for payments
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
|
||||||
@@ -227,8 +231,6 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!app->isConnected()) {
|
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("not_connected"));
|
|
||||||
} else {
|
} else {
|
||||||
// Pre-interaction placeholder: keeps the results area from reading as a large
|
// Pre-interaction placeholder: keeps the results area from reading as a large
|
||||||
// dead gap between the Validate/Paste row and the Close button until a result exists.
|
// dead gap between the Validate/Paste row and the Close button until a result exists.
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
|
|
||||||
std::string formatAmountFixed(double amount, int decimals = 8);
|
std::string formatAmountFixed(double amount, int decimals = 8);
|
||||||
|
|
||||||
|
// DRGX amounts are 8-decimal. Converting to int64 satoshis and comparing there avoids the IEEE-double
|
||||||
|
// rounding that could block a valid exact-max send (amount+fee computed in doubles landed 1 ulp over).
|
||||||
|
inline std::int64_t toSats(double coins) { return static_cast<std::int64_t>(std::llround(coins * 1e8)); }
|
||||||
|
|
||||||
|
// True when amount + fee fits within available, compared in integer satoshis (not doubles).
|
||||||
|
inline bool canAfford(double amount, double fee, double available) {
|
||||||
|
return toSats(amount) + toSats(fee) <= toSats(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a coin amount to zatoshis for a send, rejecting non-finite / non-positive / rounds-to-zero /
|
||||||
|
// over-supply values. Returns false (and leaves out untouched) on any of those.
|
||||||
|
inline bool coinsToZatoshis(double coins, std::uint64_t& out) {
|
||||||
|
if (!std::isfinite(coins) || coins <= 0.0) return false;
|
||||||
|
const std::int64_t z = toSats(coins);
|
||||||
|
if (z <= 0 || z > static_cast<std::int64_t>(21000000) * 100000000LL) return false;
|
||||||
|
out = static_cast<std::uint64_t>(z);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+7
-30
@@ -1125,23 +1125,17 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
|
|||||||
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
||||||
const bool haveSHA256 = !expectedSha.empty();
|
const bool haveSHA256 = !expectedSha.empty();
|
||||||
|
|
||||||
std::string expectedMd5;
|
// SHA-256 is now required: the collision-prone MD5 fallback was removed. (A same-origin checksum
|
||||||
bool haveMD5 = false;
|
// only protects against transport corruption, not a malicious server — true authenticity needs a
|
||||||
if (!haveSHA256) { // MD5 only as a fallback when no SHA-256 is published
|
// detached ed25519 signature like the daemon/xmrig updaters; that is a separate release-coordinated
|
||||||
std::string md5Content = httpGetString(baseUrl + "/" + kZipName + ".md5", "[bootstrap]", opt);
|
// change, since it requires publishing per-archive .sig files and pinning a key.)
|
||||||
if (cancel_requested_) return false;
|
if (!haveSHA256) {
|
||||||
expectedMd5 = parseChecksumFile(md5Content, 32);
|
DEBUG_LOGF("[Bootstrap] No SHA-256 published - refusing to install an unverified archive\n");
|
||||||
haveMD5 = !expectedMd5.empty();
|
setProgress(State::Failed, "Verification failed: no SHA-256 checksum was published for the bootstrap. "
|
||||||
}
|
|
||||||
|
|
||||||
if (!haveSHA256 && !haveMD5) {
|
|
||||||
DEBUG_LOGF("[Bootstrap] No checksum published - refusing to install an unverified archive\n");
|
|
||||||
setProgress(State::Failed, "Verification failed: no checksum was published for the bootstrap. "
|
|
||||||
"Refusing to install an unverified archive.");
|
"Refusing to install an unverified archive.");
|
||||||
return false; // fail CLOSED; archive is KEPT
|
return false; // fail CLOSED; archive is KEPT
|
||||||
}
|
}
|
||||||
|
|
||||||
if (haveSHA256) {
|
|
||||||
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
||||||
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
||||||
if (cancel_requested_) return false;
|
if (cancel_requested_) return false;
|
||||||
@@ -1158,23 +1152,6 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
|
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
|
||||||
} else { // haveMD5
|
|
||||||
setProgress(State::Verifying, "Verifying MD5...", 0, 1);
|
|
||||||
std::string actual = computeMD5(zipPath, 0.0f, 100.0f);
|
|
||||||
if (cancel_requested_) return false;
|
|
||||||
if (actual.empty()) { setProgress(State::Failed, "MD5 verification error"); return false; }
|
|
||||||
if (expectedMd5 != actual) {
|
|
||||||
last_verify_mismatch_ = true;
|
|
||||||
DEBUG_LOGF("[Bootstrap] MD5 MISMATCH!\n expected: %s\n actual: %s\n",
|
|
||||||
expectedMd5.c_str(), actual.c_str());
|
|
||||||
setProgress(State::Failed,
|
|
||||||
"MD5 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
|
||||||
"Expected: " + expectedMd5 + "\nGot: " + actual + "\n"
|
|
||||||
"Try again or use a fresh download.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
DEBUG_LOGF("[Bootstrap] MD5 verified: %s\n", actual.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
// Make a user-typed filename safe to write into a trusted config/export directory: basename only
|
||||||
|
// (no path traversal), illegal / control chars -> '_', no leading dots or trailing dots-or-spaces,
|
||||||
|
// Windows reserved device stems prefixed with '_', length-capped, and always ending in requiredExt
|
||||||
|
// (e.g. ".csv"). Returns `fallback` if nothing usable remains. Header-only so every export dialog
|
||||||
|
// shares one implementation.
|
||||||
|
inline std::string sanitizeExportFilename(std::string name, const char* requiredExt, const char* fallback) {
|
||||||
|
const std::size_t slash = name.find_last_of("/\\");
|
||||||
|
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||||
|
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||||
|
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notSpace));
|
||||||
|
name.erase(std::find_if(name.rbegin(), name.rend(), notSpace).base(), name.end());
|
||||||
|
for (char& c : name) {
|
||||||
|
const unsigned char u = static_cast<unsigned char>(c);
|
||||||
|
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' ||
|
||||||
|
c == '?' || c == '*' || c == '/' || c == '\\')
|
||||||
|
c = '_';
|
||||||
|
}
|
||||||
|
const std::size_t firstGood = name.find_first_not_of('.');
|
||||||
|
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||||
|
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||||
|
if (name.size() > 120) name.resize(120);
|
||||||
|
// Windows reserved device names (case-insensitive, checked on the stem before the first dot).
|
||||||
|
{
|
||||||
|
std::string stem = name;
|
||||||
|
const std::size_t dot = stem.find('.');
|
||||||
|
if (dot != std::string::npos) stem = stem.substr(0, dot);
|
||||||
|
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||||
|
static const char* const kReserved[] = {
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
};
|
||||||
|
for (const char* r : kReserved) if (stem == r) { name = "_" + name; break; }
|
||||||
|
}
|
||||||
|
std::string lower = name;
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
std::string lext = requiredExt ? requiredExt : "";
|
||||||
|
std::transform(lext.begin(), lext.end(), lext.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
if (!lext.empty() &&
|
||||||
|
(lower.size() < lext.size() || lower.compare(lower.size() - lext.size(), lext.size(), lext) != 0))
|
||||||
|
name += requiredExt;
|
||||||
|
if (name.empty() || (requiredExt && name == requiredExt)) name = fallback ? fallback : "export";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -60,6 +60,20 @@ int cancelXferCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
|
|||||||
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restrict the transfer AND any redirect to HTTPS so a redirect can't silently downgrade to http://,
|
||||||
|
// file://, ftp:// or an unexpected scheme. Every caller of these shared helpers (daemon/xmrig updaters,
|
||||||
|
// pool stats) uses https URLs. (Verify-peer/host stay on at the call sites.)
|
||||||
|
void restrictToHttps(CURL* curl)
|
||||||
|
{
|
||||||
|
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0: the string-form protocol setopts
|
||||||
|
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||||
|
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||||
|
#else
|
||||||
|
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
||||||
@@ -78,6 +92,7 @@ HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetO
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
||||||
|
restrictToHttps(curl);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
||||||
@@ -131,6 +146,8 @@ bool httpDownloadToFile(const std::string& url, const std::string& destPath,
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // bound redirects (this path set none before)
|
||||||
|
restrictToHttps(curl);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||||
|
|||||||
+152
-2
@@ -114,6 +114,11 @@ void I18n::overlayTranslations(const nlohmann::json& translations)
|
|||||||
|
|
||||||
bool I18n::loadLanguage(const std::string& locale)
|
bool I18n::loadLanguage(const std::string& locale)
|
||||||
{
|
{
|
||||||
|
// Exclusive for the whole load: strings_ is cleared/rebuilt below, and translateCopy() readers on
|
||||||
|
// other threads (daemon monitor) must never observe it mid-rewrite. loadBuiltinEnglish() and
|
||||||
|
// overlayTranslations() are called only from here and do not lock (no recursion).
|
||||||
|
std::unique_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
|
||||||
// Try to load from file: CWD-relative first, then exe-relative
|
// Try to load from file: CWD-relative first, then exe-relative
|
||||||
std::string rel_path = "res/lang/" + locale + ".json";
|
std::string rel_path = "res/lang/" + locale + ".json";
|
||||||
const std::string exe_dir = Platform::getExecutableDirectory();
|
const std::string exe_dir = Platform::getExecutableDirectory();
|
||||||
@@ -232,6 +237,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
|
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
|
||||||
strings_["chat_you"] = "You";
|
strings_["chat_you"] = "You";
|
||||||
strings_["chat_contact_request"] = "contact request";
|
strings_["chat_contact_request"] = "contact request";
|
||||||
|
strings_["chat_request_pill"] = "Request";
|
||||||
|
strings_["chat_request_title"] = "New contact request";
|
||||||
|
strings_["chat_request_body"] = "This person isn't in your contacts. Accept to add them, or block to delete this conversation and ignore them.";
|
||||||
|
strings_["chat_request_accept"] = "Accept";
|
||||||
|
strings_["chat_request_block"] = "Block";
|
||||||
|
strings_["chat_request_accepted"] = "Contact added.";
|
||||||
strings_["chat_send_failed"] = "not sent";
|
strings_["chat_send_failed"] = "not sent";
|
||||||
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
|
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
|
||||||
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";
|
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";
|
||||||
@@ -262,6 +273,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_retry"] = "Retry";
|
strings_["chat_retry"] = "Retry";
|
||||||
strings_["chat_jump_latest"] = "Latest";
|
strings_["chat_jump_latest"] = "Latest";
|
||||||
strings_["chat_empty_title"] = "No conversations yet";
|
strings_["chat_empty_title"] = "No conversations yet";
|
||||||
|
strings_["chat_intro_title"] = "How chat works";
|
||||||
|
strings_["chat_intro_body"] = "Messages are end-to-end encrypted and sent as shielded transactions, so only you and your contact can read them. Your chat identity is tied to your wallet seed, so restoring your seed restores your chat address.";
|
||||||
|
strings_["chat_intro_fee"] = "Each message costs a small network fee. Share your chat address to let people start a conversation with you.";
|
||||||
|
strings_["chat_intro_dismiss"] = "Got it";
|
||||||
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
|
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
|
||||||
strings_["chat_copy_my_address"] = "Copy my chat address";
|
strings_["chat_copy_my_address"] = "Copy my chat address";
|
||||||
strings_["chat_my_address_tip"] = "Share this address so others can message you";
|
strings_["chat_my_address_tip"] = "Share this address so others can message you";
|
||||||
@@ -272,6 +287,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
|
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
|
||||||
strings_["chat_export_done"] = "Conversation exported";
|
strings_["chat_export_done"] = "Conversation exported";
|
||||||
strings_["chat_export_failed"] = "Could not write the export file.";
|
strings_["chat_export_failed"] = "Could not write the export file.";
|
||||||
|
strings_["chat_export_confirm_title"] = "Export chat as plain text?";
|
||||||
|
strings_["chat_export_confirm_body"] = "This saves the decrypted messages as an UNENCRYPTED plain-text file. Anyone who can open the file can read the whole conversation. Store it securely and delete it when you no longer need it.";
|
||||||
|
strings_["chat_export_confirm_path"] = "Will be saved to:";
|
||||||
|
strings_["chat_export_confirm_btn"] = "Export";
|
||||||
strings_["chat_len_over"] = "Message too long";
|
strings_["chat_len_over"] = "Message too long";
|
||||||
strings_["chat_mute"] = "Mute";
|
strings_["chat_mute"] = "Mute";
|
||||||
strings_["chat_unmute"] = "Unmute";
|
strings_["chat_unmute"] = "Unmute";
|
||||||
@@ -305,6 +324,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["contacts_message_needs_z"] = "Chat needs a shielded (z) address — this contact only has a transparent one";
|
strings_["contacts_message_needs_z"] = "Chat needs a shielded (z) address — this contact only has a transparent one";
|
||||||
strings_["chat_copy_address_tip"] = "Click to copy address";
|
strings_["chat_copy_address_tip"] = "Click to copy address";
|
||||||
strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify";
|
strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify";
|
||||||
|
strings_["chat_verify_tip"] = "End-to-end encrypted to this contact's identity key. Click to view the key fingerprint and compare it with your contact over a separate channel (call or in person) to be sure no one is impersonating them.";
|
||||||
|
strings_["chat_verify_title"] = "Verify contact key";
|
||||||
|
strings_["chat_verify_body"] = "Messages in this chat are encrypted to the identity key below, which was learned from this contact's first message. Ask your contact to read you their key fingerprint over a separate channel (a call or in person). If it matches exactly, you are talking to the right person; if not, do not trust this conversation.";
|
||||||
|
strings_["chat_verify_copy"] = "Copy key";
|
||||||
strings_["chat_awaiting_key"] = "Waiting for reply";
|
strings_["chat_awaiting_key"] = "Waiting for reply";
|
||||||
strings_["chat_rename"] = "Rename contact";
|
strings_["chat_rename"] = "Rename contact";
|
||||||
strings_["chat_rename_hint"] = "Contact name";
|
strings_["chat_rename_hint"] = "Contact name";
|
||||||
@@ -464,6 +487,23 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_receive_addr"] = "New wallet receive address:";
|
strings_["mig_receive_addr"] = "New wallet receive address:";
|
||||||
strings_["mig_copy_seed"] = "Copy seed";
|
strings_["mig_copy_seed"] = "Copy seed";
|
||||||
strings_["mig_backed_up"] = "I've written down my seed phrase";
|
strings_["mig_backed_up"] = "I've written down my seed phrase";
|
||||||
|
strings_["err_not_connected_daemon"] = "Not connected to the daemon.";
|
||||||
|
strings_["sweep_err_need_spending_key"] = "Enter a spending key (a viewing key can't move funds).";
|
||||||
|
strings_["sweep_status_importing"] = "Importing key & rescanning…";
|
||||||
|
strings_["sweep_err_unknown"] = "Sweep failed (unknown error)";
|
||||||
|
strings_["sweep_err_nofunds"] = "This key holds no funds to sweep.";
|
||||||
|
strings_["sweep_err_unconfirmed"] = "This key's funds are still unconfirmed — try again shortly.";
|
||||||
|
strings_["sweep_err_already"] = "This key is already in your wallet — use Send to move its funds.";
|
||||||
|
strings_["sweep_err_dust"] = "This key's balance is too small to cover the network fee.";
|
||||||
|
strings_["sweep_status_sweeping"] = "Sweeping funds to your address…";
|
||||||
|
strings_["migrate_status_checking_sweep"] = "Checking on the previous sweep…";
|
||||||
|
strings_["migrate_err_no_dest"] = "No destination address for the sweep.";
|
||||||
|
strings_["migrate_status_building"] = "Building the sweep transaction…";
|
||||||
|
strings_["migrate_status_waiting_accept"] = "Waiting for the sweep transaction to be accepted…";
|
||||||
|
strings_["migrate_status_restarting"] = "Restarting with your new wallet — this rescan can take a while…";
|
||||||
|
strings_["rescan_status_from_block"] = "Rescanning from block %d...";
|
||||||
|
strings_["mig_understood"] = "I understand that once I continue, my funds will be moved into this new wallet and can only be recovered with this seed phrase";
|
||||||
|
strings_["mig_ack_required"] = "Confirm both statements above to continue";
|
||||||
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
|
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
|
||||||
strings_["mig_continue_sweep"] = "Continue to sweep";
|
strings_["mig_continue_sweep"] = "Continue to sweep";
|
||||||
strings_["mig_discard"] = "Discard migration";
|
strings_["mig_discard"] = "Discard migration";
|
||||||
@@ -472,6 +512,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_to"] = "To: ";
|
strings_["mig_to"] = "To: ";
|
||||||
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
||||||
strings_["mig_sweep_all"] = "Sweep all funds";
|
strings_["mig_sweep_all"] = "Sweep all funds";
|
||||||
|
strings_["mig_unlock_prompt"] = "Your wallet is locked. Enter your passphrase to unlock it, then sweep.";
|
||||||
|
strings_["mig_unlock_btn"] = "Unlock";
|
||||||
strings_["mig_later"] = "Later";
|
strings_["mig_later"] = "Later";
|
||||||
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
||||||
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
||||||
@@ -481,7 +523,13 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_adopt"] = "Make this my wallet";
|
strings_["mig_adopt"] = "Make this my wallet";
|
||||||
strings_["mig_remainder"] = "Some funds are still in your old wallet (too many inputs for one transaction) — sweep the remainder before switching.";
|
strings_["mig_remainder"] = "Some funds are still in your old wallet (too many inputs for one transaction) — sweep the remainder before switching.";
|
||||||
strings_["mig_sweep_remaining"] = "Sweep remaining";
|
strings_["mig_sweep_remaining"] = "Sweep remaining";
|
||||||
|
strings_["mig_discard_confirm"] = "Discard this migration? The new seed wallet created on this device will be deleted. Make sure you have not sent anything to it. Your current wallet is not changed.";
|
||||||
|
strings_["mig_discard_yes"] = "Yes, discard";
|
||||||
|
strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds.";
|
||||||
|
strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds";
|
||||||
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
||||||
|
strings_["mig_waiting_depth"] = "Sweep mined. Waiting for %d confirmations before switching wallets, so a short chain reorganisation cannot undo it…";
|
||||||
|
strings_["mig_adopt_reverify_failed"] = "Could not confirm it is safe to switch wallets yet (the node may still be syncing, the sweep is not deep enough, or funds remain). Nothing was changed - wait and try again.";
|
||||||
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
||||||
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
||||||
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
||||||
@@ -582,6 +630,15 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["settings_merge_to_address"] = "Merge to Address…";
|
strings_["settings_merge_to_address"] = "Merge to Address…";
|
||||||
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
||||||
strings_["settings_import_key"] = "Import Private Key…";
|
strings_["settings_import_key"] = "Import Private Key…";
|
||||||
|
strings_["settings_import_wallet_file"] = "Import From Backup File…";
|
||||||
|
strings_["import_wallet_file_title"] = "Import keys from a backup file";
|
||||||
|
strings_["import_wallet_file_note"] = "Imports every private key from an old wallet's export file (its z_exportwallet / dumpwallet output, or SilentDragon's \"Export all private keys\") and rescans once — the fastest way to recover a legacy wallet.dat this client can't open.";
|
||||||
|
strings_["import_wallet_file_path_label"] = "Path to the key dump file:";
|
||||||
|
strings_["import_wallet_file_import_btn"] = "Import and rescan";
|
||||||
|
strings_["import_wallet_file_importing"] = "Importing keys and rescanning — this can take a while";
|
||||||
|
strings_["import_wallet_file_ok"] = "Keys imported. Once the rescan finishes, shield any mined (coinbase) transparent funds from the Shield screen to make them spendable.";
|
||||||
|
strings_["import_wallet_file_need_path"] = "Enter the path to the key dump file.";
|
||||||
|
strings_["import_wallet_file_not_found"] = "Couldn't open that file — check the path is correct and the file is readable.";
|
||||||
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
||||||
strings_["settings_export_key"] = "Export Key…";
|
strings_["settings_export_key"] = "Export Key…";
|
||||||
strings_["settings_export_all"] = "Export All…";
|
strings_["settings_export_all"] = "Export All…";
|
||||||
@@ -688,6 +745,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wiz_bootstrap_desc"] = "Download a blockchain bootstrap to dramatically speed up initial sync.\n\nYour existing wallet.dat will NOT be modified or replaced.";
|
strings_["wiz_bootstrap_desc"] = "Download a blockchain bootstrap to dramatically speed up initial sync.\n\nYour existing wallet.dat will NOT be modified or replaced.";
|
||||||
strings_["wiz_skip"] = "Skip";
|
strings_["wiz_skip"] = "Skip";
|
||||||
strings_["wiz_bootstrap_failed"] = "Bootstrap failed";
|
strings_["wiz_bootstrap_failed"] = "Bootstrap failed";
|
||||||
|
strings_["bootstrap_try_mirror"] = "Try mirror";
|
||||||
strings_["wiz_download_failed"] = "Download Failed";
|
strings_["wiz_download_failed"] = "Download Failed";
|
||||||
strings_["wiz_ext_daemon_running"] = "External daemon running";
|
strings_["wiz_ext_daemon_running"] = "External daemon running";
|
||||||
strings_["wiz_ext_daemon_warning"] = "It must be stopped before downloading a bootstrap, otherwise chain data could be corrupted.";
|
strings_["wiz_ext_daemon_warning"] = "It must be stopped before downloading a bootstrap, otherwise chain data could be corrupted.";
|
||||||
@@ -756,6 +814,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them";
|
strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them";
|
||||||
strings_["wiz_encrypt_continue"] = "Encrypt & Continue";
|
strings_["wiz_encrypt_continue"] = "Encrypt & Continue";
|
||||||
strings_["wiz_encrypt_bg"] = "Encryption will complete in the background";
|
strings_["wiz_encrypt_bg"] = "Encryption will complete in the background";
|
||||||
|
strings_["wiz_handoff_starting"] = "Setting up your wallet. The node is starting and will keep syncing in the background. You can start exploring now, but balances may take a while to appear.";
|
||||||
|
strings_["wiz_handoff_encrypting"] = "Setting up your wallet. The node is starting, and encryption will finish in the background once it connects. Balances may take a while to appear.";
|
||||||
strings_["wiz_skip_confirm"] = "Continue WITHOUT encryption? Keys will be stored unencrypted \xe2\x80\x94 click Skip again to confirm.";
|
strings_["wiz_skip_confirm"] = "Continue WITHOUT encryption? Keys will be stored unencrypted \xe2\x80\x94 click Skip again to confirm.";
|
||||||
strings_["wiz_pin_optional"] = "Quick-Unlock PIN (optional)";
|
strings_["wiz_pin_optional"] = "Quick-Unlock PIN (optional)";
|
||||||
strings_["wiz_pin_label"] = "PIN (4-8 digits):";
|
strings_["wiz_pin_label"] = "PIN (4-8 digits):";
|
||||||
@@ -892,6 +952,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["bootstrap_trust_warning"] = "Only use bootstrap.dragonx.is or bootstrap2.dragonx.is. Using files from untrusted sources could compromise your node.";
|
strings_["bootstrap_trust_warning"] = "Only use bootstrap.dragonx.is or bootstrap2.dragonx.is. Using files from untrusted sources could compromise your node.";
|
||||||
strings_["bootstrap_mirror"] = "Mirror";
|
strings_["bootstrap_mirror"] = "Mirror";
|
||||||
strings_["bootstrap_mirror_tooltip"] = "Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.";
|
strings_["bootstrap_mirror_tooltip"] = "Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.";
|
||||||
|
strings_["bootstrap_use_mirror"] = "Use mirror";
|
||||||
|
strings_["bootstrap_use_mirror_tooltip"] = "Download the same bootstrap from the backup server (bootstrap2.dragonx.is) instead of the main one (bootstrap.dragonx.is).\nChoose this if the main download is slow or failing.";
|
||||||
|
strings_["wiz_bootstrap_size_hint"] = "This is a large download. The exact size and a time estimate appear once it starts, and it can be resumed if interrupted.";
|
||||||
strings_["bootstrap_downloading"] = "Downloading bootstrap...";
|
strings_["bootstrap_downloading"] = "Downloading bootstrap...";
|
||||||
strings_["bootstrap_verifying"] = "Verifying checksums...";
|
strings_["bootstrap_verifying"] = "Verifying checksums...";
|
||||||
strings_["bootstrap_extracting"] = "Extracting blockchain data...";
|
strings_["bootstrap_extracting"] = "Extracting blockchain data...";
|
||||||
@@ -902,6 +965,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["bootstrap_success"] = "Bootstrap Complete";
|
strings_["bootstrap_success"] = "Bootstrap Complete";
|
||||||
strings_["bootstrap_success_desc"] = "Blockchain data has been extracted successfully. Start the daemon to begin syncing from the bootstrap point.";
|
strings_["bootstrap_success_desc"] = "Blockchain data has been extracted successfully. Start the daemon to begin syncing from the bootstrap point.";
|
||||||
strings_["bootstrap_restart_daemon"] = "Restart Daemon";
|
strings_["bootstrap_restart_daemon"] = "Restart Daemon";
|
||||||
|
strings_["bootstrap_start_daemon"] = "Start Daemon";
|
||||||
|
strings_["bootstrap_success_desc_start"] = "Blockchain data was extracted successfully. Click Start Daemon to begin syncing from the bootstrap point.";
|
||||||
strings_["bootstrap_failed"] = "Bootstrap Failed";
|
strings_["bootstrap_failed"] = "Bootstrap Failed";
|
||||||
strings_["tt_open_dir"] = "Click to open in file explorer";
|
strings_["tt_open_dir"] = "Click to open in file explorer";
|
||||||
strings_["settings_open_data_dir"] = "Open data folder";
|
strings_["settings_open_data_dir"] = "Open data folder";
|
||||||
@@ -1036,10 +1101,14 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)";
|
strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)";
|
||||||
strings_["tt_debug_collapse"] = "Collapse debug logging options";
|
strings_["tt_debug_collapse"] = "Collapse debug logging options";
|
||||||
strings_["tt_debug_expand"] = "Expand debug logging options";
|
strings_["tt_debug_expand"] = "Expand debug logging options";
|
||||||
|
strings_["debug_options_caption_gated"] = "Diagnostics and node tuning for troubleshooting. Locked: opening requires confirmation (and your PIN or passphrase if set).";
|
||||||
|
strings_["debug_options_caption_unlocked"] = "Diagnostics and node tuning for troubleshooting. Unlocked for this session.";
|
||||||
|
strings_["tt_debug_expand_gated"] = "Locked: confirm (and enter your PIN or passphrase if set) to show debug options";
|
||||||
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
|
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
|
||||||
|
|
||||||
// Settings: additional tooltips (keys/data row)
|
// Settings: additional tooltips (keys/data row)
|
||||||
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
||||||
|
strings_["tt_import_wallet_file"] = "Import all keys from an old wallet's exported dump file (one rescan)";
|
||||||
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
||||||
strings_["tt_export_key"] = "Export the private key for the selected address";
|
strings_["tt_export_key"] = "Export the private key for the selected address";
|
||||||
strings_["tt_export_all"] = "Export all private keys to a file";
|
strings_["tt_export_all"] = "Export all private keys to a file";
|
||||||
@@ -1139,6 +1208,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["recent_sends"] = "RECENT SENDS";
|
strings_["recent_sends"] = "RECENT SENDS";
|
||||||
strings_["recipient"] = "RECIPIENT";
|
strings_["recipient"] = "RECIPIENT";
|
||||||
strings_["memo_optional"] = "MEMO (OPTIONAL)";
|
strings_["memo_optional"] = "MEMO (OPTIONAL)";
|
||||||
|
strings_["send_memo_requires_shielded"] = "Memos require a shielded (z) recipient address.";
|
||||||
|
strings_["send_memo_will_not_be_sent"] = "This memo will not be sent: memos only work with shielded (z) recipients.";
|
||||||
strings_["no_recent_sends"] = "No recent sends";
|
strings_["no_recent_sends"] = "No recent sends";
|
||||||
strings_["network_fee"] = "NETWORK FEE";
|
strings_["network_fee"] = "NETWORK FEE";
|
||||||
strings_["amount_details"] = "AMOUNT DETAILS";
|
strings_["amount_details"] = "AMOUNT DETAILS";
|
||||||
@@ -1300,6 +1371,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["lite_unlock_btn"] = "Unlock";
|
strings_["lite_unlock_btn"] = "Unlock";
|
||||||
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
||||||
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
||||||
|
strings_["lite_encrypt_failed_unencrypted"] = "Wallet encryption failed — your wallet may still be UNENCRYPTED. Open Settings > Security and set the passphrase again.";
|
||||||
|
|
||||||
// Send Tab
|
// Send Tab
|
||||||
strings_["pay_from"] = "Pay From";
|
strings_["pay_from"] = "Pay From";
|
||||||
@@ -1451,6 +1523,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["confirmations"] = "Confirmations";
|
strings_["confirmations"] = "Confirmations";
|
||||||
strings_["confirmed"] = "Confirmed";
|
strings_["confirmed"] = "Confirmed";
|
||||||
strings_["pending"] = "Pending";
|
strings_["pending"] = "Pending";
|
||||||
|
strings_["tx_dropped"] = "Dropped";
|
||||||
|
strings_["tx_stale_pending"] = "Expired?";
|
||||||
|
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
||||||
|
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
||||||
strings_["sent"] = "sent";
|
strings_["sent"] = "sent";
|
||||||
strings_["received"] = "received";
|
strings_["received"] = "received";
|
||||||
strings_["mined"] = "mined";
|
strings_["mined"] = "mined";
|
||||||
@@ -1541,6 +1617,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_mining_hs"] = "%.1f H/s";
|
strings_["sb_mining_hs"] = "%.1f H/s";
|
||||||
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
||||||
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
||||||
|
strings_["sb_lite_sync_error"] = "Sync error: %.80s";
|
||||||
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
||||||
strings_["sb_rescanning"] = "Rescanning";
|
strings_["sb_rescanning"] = "Rescanning";
|
||||||
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
||||||
@@ -1553,7 +1630,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_no_conf"] = "No DRAGONX.conf found";
|
strings_["sb_no_conf"] = "No DRAGONX.conf found";
|
||||||
strings_["sb_starting_daemon"] = "Starting dragonxd...";
|
strings_["sb_starting_daemon"] = "Starting dragonxd...";
|
||||||
strings_["sb_connecting_daemon"] = "Connecting to dragonxd...";
|
strings_["sb_connecting_daemon"] = "Connecting to dragonxd...";
|
||||||
strings_["sb_auth_failed"] = "Auth failed — check rpcuser/rpcpassword";
|
strings_["sb_auth_failed"] = "Couldn't sign in to the node";
|
||||||
strings_["sb_waiting_daemon"] = "Waiting for dragonxd to start...";
|
strings_["sb_waiting_daemon"] = "Waiting for dragonxd to start...";
|
||||||
strings_["sb_waiting_daemon_err"] = "Waiting for dragonxd — %s";
|
strings_["sb_waiting_daemon_err"] = "Waiting for dragonxd — %s";
|
||||||
strings_["sb_connecting_external"] = "Connecting to external daemon...";
|
strings_["sb_connecting_external"] = "Connecting to external daemon...";
|
||||||
@@ -1571,6 +1648,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||||
strings_["node_banner_reconnect"] = "Reconnect";
|
strings_["node_banner_reconnect"] = "Reconnect";
|
||||||
strings_["node_banner_restart"] = "Restart node";
|
strings_["node_banner_restart"] = "Restart node";
|
||||||
|
strings_["node_banner_open_config"] = "Open config folder";
|
||||||
|
strings_["node_banner_auth_title"] = "Wallet can't sign in to the node";
|
||||||
|
strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
|
||||||
|
strings_["node_banner_legacy_title"] = "Your transparent funds can't be moved yet";
|
||||||
|
strings_["node_banner_legacy_detail"] = "This wallet has no usable seed, so it can't create a shielded address to move its transparent funds. Recover them into a fresh seed-backed wallet.";
|
||||||
|
strings_["node_banner_recover"] = "Recover funds";
|
||||||
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
||||||
strings_["data_stale_prefix"] = "Updated";
|
strings_["data_stale_prefix"] = "Updated";
|
||||||
strings_["data_stale_tooltip"] =
|
strings_["data_stale_tooltip"] =
|
||||||
@@ -1954,9 +2037,17 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["toast_pool_connecting"] = "Starting pool miner — connecting to the pool…";
|
strings_["toast_pool_connecting"] = "Starting pool miner — connecting to the pool…";
|
||||||
strings_["toast_not_connected_daemon"] = "Not connected to the node.";
|
strings_["toast_not_connected_daemon"] = "Not connected to the node.";
|
||||||
strings_["toast_mining_failed"] = "Couldn't start mining: ";
|
strings_["toast_mining_failed"] = "Couldn't start mining: ";
|
||||||
|
strings_["toast_pool_payout_invalid"] = "Pool payout address is not a valid DragonX address — mining not started.";
|
||||||
|
strings_["toast_av_blocked_xmrig"] = "Windows Defender blocked the miner. Add an exclusion for %APPDATA%\\ObsidianDragon.";
|
||||||
|
strings_["mining_error_retry_hint"] = "Press Mine to try again.";
|
||||||
|
strings_["mining_error_av_help"] = "How to fix";
|
||||||
|
strings_["mining_error_dismiss"] = "Dismiss";
|
||||||
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
|
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
|
||||||
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
|
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
|
||||||
strings_["toast_ban_failed"] = "Couldn't ban the peer: ";
|
strings_["toast_ban_failed"] = "Couldn't ban the peer: ";
|
||||||
|
strings_["toast_peer_banned"] = "Peer banned.";
|
||||||
|
strings_["toast_peer_unbanned"] = "Peer unbanned.";
|
||||||
|
strings_["toast_bans_cleared"] = "All bans cleared.";
|
||||||
strings_["toast_unban_failed"] = "Couldn't unban the peer: ";
|
strings_["toast_unban_failed"] = "Couldn't unban the peer: ";
|
||||||
strings_["toast_clear_bans_failed"] = "Couldn't clear bans: ";
|
strings_["toast_clear_bans_failed"] = "Couldn't clear bans: ";
|
||||||
strings_["toast_key_export_failed"] = "Key export failed: ";
|
strings_["toast_key_export_failed"] = "Key export failed: ";
|
||||||
@@ -1966,6 +2057,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["console_clear"] = "Clear";
|
strings_["console_clear"] = "Clear";
|
||||||
strings_["console_clear_console"] = "Clear Console";
|
strings_["console_clear_console"] = "Clear Console";
|
||||||
strings_["console_cleared"] = "Console cleared";
|
strings_["console_cleared"] = "Console cleared";
|
||||||
|
strings_["console_secret_result_hidden"] = "[result hidden — contains private key material. Use \"Copy secret\" to copy it, or it clears automatically.]";
|
||||||
|
strings_["console_secret_result_expired"] = "[hidden secret result cleared]";
|
||||||
|
strings_["console_copy_secret"] = "Copy secret";
|
||||||
|
strings_["console_copy_secret_tip"] = "Copy the hidden key/seed result to the clipboard (auto-clears after ~45s).";
|
||||||
|
strings_["console_hide_secret"] = "Hide";
|
||||||
strings_["console_click_commands"] = "Click commands above to insert them";
|
strings_["console_click_commands"] = "Click commands above to insert them";
|
||||||
strings_["console_click_insert"] = "Click to insert";
|
strings_["console_click_insert"] = "Click to insert";
|
||||||
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
||||||
@@ -2080,6 +2176,17 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["export_tx_file_fail"] = "Failed to create CSV file";
|
strings_["export_tx_file_fail"] = "Failed to create CSV file";
|
||||||
strings_["export_tx_none"] = "No transactions to export";
|
strings_["export_tx_none"] = "No transactions to export";
|
||||||
strings_["export_tx_success"] = "Transactions exported successfully";
|
strings_["export_tx_success"] = "Transactions exported successfully";
|
||||||
|
strings_["export_tx_open_folder"] = "Open folder";
|
||||||
|
strings_["export_tx_scope"] = "Export scope";
|
||||||
|
strings_["export_tx_scope_all"] = "All transactions";
|
||||||
|
strings_["export_tx_scope_filter"] = "Current History filter";
|
||||||
|
strings_["export_tx_scope_range"] = "Date range";
|
||||||
|
strings_["export_tx_scope_filter_none"] = "No filter is active on the History tab";
|
||||||
|
strings_["export_tx_scope_stale"] = "The History filter changed while loading. Close and reopen this dialog.";
|
||||||
|
strings_["export_tx_range_from"] = "From (YYYY-MM-DD)";
|
||||||
|
strings_["export_tx_range_to"] = "To (YYYY-MM-DD)";
|
||||||
|
strings_["export_tx_range_invalid"] = "Enter valid dates as YYYY-MM-DD, with From on or before To";
|
||||||
|
strings_["export_tx_range_pending_note"] = "Pending transactions have no date and are not included in a date range.";
|
||||||
strings_["export_tx_title"] = "Export Transactions to CSV";
|
strings_["export_tx_title"] = "Export Transactions to CSV";
|
||||||
|
|
||||||
// --- Import Key Dialog ---
|
// --- Import Key Dialog ---
|
||||||
@@ -2131,6 +2238,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
|
|
||||||
// --- Key Export Dialog ---
|
// --- Key Export Dialog ---
|
||||||
strings_["key_export_fetching"] = "Fetching key from wallet...";
|
strings_["key_export_fetching"] = "Fetching key from wallet...";
|
||||||
|
strings_["key_export_fetching_base"] = "Fetching key from wallet";
|
||||||
|
strings_["key_export_unlock_first"] = "Unlock your wallet first, then reveal the key.";
|
||||||
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
|
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
|
||||||
strings_["key_export_private_key"] = "Private Key:";
|
strings_["key_export_private_key"] = "Private Key:";
|
||||||
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
|
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
|
||||||
@@ -2255,6 +2364,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mining_click_copy_difficulty"] = "Click to copy difficulty";
|
strings_["mining_click_copy_difficulty"] = "Click to copy difficulty";
|
||||||
strings_["mining_connected"] = "Connected";
|
strings_["mining_connected"] = "Connected";
|
||||||
strings_["mining_connecting"] = "Connecting...";
|
strings_["mining_connecting"] = "Connecting...";
|
||||||
|
strings_["mining_status_label"] = "Status";
|
||||||
|
strings_["mining_status_connected"] = "Connected";
|
||||||
|
strings_["mining_status_connecting"] = "Connecting…";
|
||||||
|
strings_["mining_status_stopped"] = "Stopped";
|
||||||
|
strings_["mining_pool_unreachable"] = "Can't reach pool — check the URL";
|
||||||
strings_["mining_difficulty_copied"] = "Difficulty copied";
|
strings_["mining_difficulty_copied"] = "Difficulty copied";
|
||||||
strings_["mining_est_block"] = "Est. Block";
|
strings_["mining_est_block"] = "Est. Block";
|
||||||
strings_["mining_est_daily"] = "Est. Daily";
|
strings_["mining_est_daily"] = "Est. Daily";
|
||||||
@@ -2290,6 +2404,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_mining_addr_short"] = "Mining address — can't be paid";
|
strings_["send_mining_addr_short"] = "Mining address — can't be paid";
|
||||||
strings_["send_mining_addr_blocked"] = "This is a mining (transparent) address. On DragonX these can only receive mining rewards — the network rejects payments to them. Ask the recipient for a shielded address (it starts with zs).";
|
strings_["send_mining_addr_blocked"] = "This is a mining (transparent) address. On DragonX these can only receive mining rewards — the network rejects payments to them. Ask the recipient for a shielded address (it starts with zs).";
|
||||||
strings_["mining_payout_needs_shielded"] = "Pools pay out with ordinary transactions, which can't reach a mining (transparent, R…) address on DragonX. Use a shielded address (it starts with zs) as your payout address.";
|
strings_["mining_payout_needs_shielded"] = "Pools pay out with ordinary transactions, which can't reach a mining (transparent, R…) address on DragonX. Use a shielded address (it starts with zs) as your payout address.";
|
||||||
|
strings_["mining_payout_invalid_short"] = "Not a valid DragonX address";
|
||||||
|
strings_["mining_payout_needs_shielded_short"] = "Use a shielded (zs…) address, not a mining address";
|
||||||
strings_["new_mining_address"] = "New mining address";
|
strings_["new_mining_address"] = "New mining address";
|
||||||
strings_["tt_new_mining_address"] = "Creates a transparent (R…) address for solo-mining rewards. It can't receive payments — share a shielded address for those.";
|
strings_["tt_new_mining_address"] = "Creates a transparent (R…) address for solo-mining rewards. It can't receive payments — share a shielded address for those.";
|
||||||
strings_["mining_address"] = "Mining address";
|
strings_["mining_address"] = "Mining address";
|
||||||
@@ -2301,10 +2417,13 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["balance_mining_fmt"] = "Mining rewards: %.8f";
|
strings_["balance_mining_fmt"] = "Mining rewards: %.8f";
|
||||||
strings_["mining_card_tooltip"] = "Solo-mining rewards arrive at your mining (transparent, R…) addresses. Those addresses can't receive payments. Rewards are shielded automatically once they mature.";
|
strings_["mining_card_tooltip"] = "Solo-mining rewards arrive at your mining (transparent, R…) addresses. Those addresses can't receive payments. Rewards are shielded automatically once they mature.";
|
||||||
strings_["mining_card_click_to_shield"] = "Click to shield matured rewards now.";
|
strings_["mining_card_click_to_shield"] = "Click to shield matured rewards now.";
|
||||||
|
strings_["shielded_card_tooltip"] = "Shielded (private) balance: funds held in z-addresses. Amounts, senders and recipients are hidden on the blockchain. Click to open Receive.";
|
||||||
strings_["contact_chip_mining"] = "Mining only";
|
strings_["contact_chip_mining"] = "Mining only";
|
||||||
strings_["failed_create_mining_address"] = "Failed to create mining address";
|
strings_["failed_create_mining_address"] = "Failed to create mining address";
|
||||||
strings_["new_mining_address_created"] = "New mining address created";
|
strings_["new_mining_address_created"] = "New mining address created";
|
||||||
strings_["mining_addr_explainer"] = "Receives solo-mining rewards only — the network rejects payments to it. Rewards are shielded automatically once they mature.";
|
strings_["mining_addr_explainer"] = "Receives solo-mining rewards only — the network rejects payments to it. Rewards are shielded automatically once they mature.";
|
||||||
|
strings_["mining_addr_none"] = "None yet";
|
||||||
|
strings_["mining_addr_none_tip"] = "No mining address yet. Create one in the Receive tab (New mining address). Solo-mining rewards are paid to a transparent (R...) mining address, then shielded automatically once they mature.";
|
||||||
strings_["receive_use_shielded"] = "Use a shielded address";
|
strings_["receive_use_shielded"] = "Use a shielded address";
|
||||||
strings_["receive_not_for_payments"] = "Not for payments";
|
strings_["receive_not_for_payments"] = "Not for payments";
|
||||||
strings_["validate_mining_type"] = "Mining address (transparent)";
|
strings_["validate_mining_type"] = "Mining address (transparent)";
|
||||||
@@ -2362,6 +2481,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mining_to_save"] = "to save";
|
strings_["mining_to_save"] = "to save";
|
||||||
strings_["mining_today"] = "Today";
|
strings_["mining_today"] = "Today";
|
||||||
strings_["mining_uptime"] = "Uptime";
|
strings_["mining_uptime"] = "Uptime";
|
||||||
|
strings_["mining_txn_count"] = "(%d txn)";
|
||||||
|
strings_["mining_uptime_ms"] = "%lldm %llds";
|
||||||
|
strings_["mining_uptime_hm"] = "%lldh %lldm";
|
||||||
strings_["mining_yesterday"] = "Yesterday";
|
strings_["mining_yesterday"] = "Yesterday";
|
||||||
|
|
||||||
// --- Miner (xmrig) updater ---
|
// --- Miner (xmrig) updater ---
|
||||||
@@ -2435,6 +2557,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["daemon_update_installed_ok"] = "Node installed";
|
strings_["daemon_update_installed_ok"] = "Node installed";
|
||||||
strings_["daemon_update_restart_note"] = "Restart the daemon to start running the new version.";
|
strings_["daemon_update_restart_note"] = "Restart the daemon to start running the new version.";
|
||||||
strings_["daemon_update_restart_now"] = "Restart daemon now";
|
strings_["daemon_update_restart_now"] = "Restart daemon now";
|
||||||
|
strings_["daemon_update_restart_needed"] = "Restart required to use the new version";
|
||||||
|
strings_["daemon_update_later_hint"] = "A reminder stays in Settings until you restart the daemon.";
|
||||||
|
strings_["daemon_restart_pending"] = "A new daemon version is installed but not running yet. Restart the daemon to use it.";
|
||||||
|
strings_["tt_daemon_restart_pending"] = "Restarts the embedded daemon so the newly installed version takes effect.";
|
||||||
strings_["daemon_update_later"] = "Later";
|
strings_["daemon_update_later"] = "Later";
|
||||||
strings_["daemon_update_failed"] = "Update failed";
|
strings_["daemon_update_failed"] = "Update failed";
|
||||||
strings_["daemon_update_unknown_error"] = "Unknown error.";
|
strings_["daemon_update_unknown_error"] = "Unknown error.";
|
||||||
@@ -2491,6 +2617,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["peers_blocks"] = "Blocks";
|
strings_["peers_blocks"] = "Blocks";
|
||||||
strings_["peers_blocks_left"] = "%d blocks left";
|
strings_["peers_blocks_left"] = "%d blocks left";
|
||||||
strings_["peers_clear_all_bans"] = "Clear All Bans";
|
strings_["peers_clear_all_bans"] = "Clear All Bans";
|
||||||
|
strings_["peers_clear_bans_confirm"] = "Clear all %d bans?";
|
||||||
|
strings_["peers_clear_bans_confirm_yes"] = "Clear all";
|
||||||
strings_["peers_click_copy"] = "Click to copy";
|
strings_["peers_click_copy"] = "Click to copy";
|
||||||
strings_["peers_connected"] = "Connected";
|
strings_["peers_connected"] = "Connected";
|
||||||
strings_["peers_connected_count"] = "Connected: %d";
|
strings_["peers_connected_count"] = "Connected: %d";
|
||||||
@@ -2547,6 +2675,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["request_payment_uri"] = "Payment URI:";
|
strings_["request_payment_uri"] = "Payment URI:";
|
||||||
strings_["request_receive_address"] = "Receive Address:";
|
strings_["request_receive_address"] = "Receive Address:";
|
||||||
strings_["request_select_address"] = "Select address...";
|
strings_["request_select_address"] = "Select address...";
|
||||||
|
strings_["request_no_shielded"] = "You don't have a shielded address yet. Create one to receive payments.";
|
||||||
|
strings_["request_create_shielded"] = "Create a shielded address";
|
||||||
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
|
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
|
||||||
strings_["request_title"] = "Request Payment";
|
strings_["request_title"] = "Request Payment";
|
||||||
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
|
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
|
||||||
@@ -2556,6 +2686,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_amount_details"] = "AMOUNT DETAILS";
|
strings_["send_amount_details"] = "AMOUNT DETAILS";
|
||||||
strings_["send_amount_upper"] = "AMOUNT";
|
strings_["send_amount_upper"] = "AMOUNT";
|
||||||
strings_["send_clear_fields"] = "Clear all form fields?";
|
strings_["send_clear_fields"] = "Clear all form fields?";
|
||||||
|
strings_["send_clear_form"] = "Clear form";
|
||||||
|
strings_["send_clear_form_tip"] = "Clear the recipient, amount and memo. You can undo for a few seconds.";
|
||||||
strings_["send_copy_error"] = "Copy Error";
|
strings_["send_copy_error"] = "Copy Error";
|
||||||
strings_["send_dismiss"] = "Dismiss";
|
strings_["send_dismiss"] = "Dismiss";
|
||||||
strings_["send_error_copied"] = "Error copied to clipboard";
|
strings_["send_error_copied"] = "Error copied to clipboard";
|
||||||
@@ -2572,6 +2704,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_network_fee_label"] = "Network fee:";
|
strings_["send_network_fee_label"] = "Network fee:";
|
||||||
strings_["send_total_label"] = "Total:";
|
strings_["send_total_label"] = "Total:";
|
||||||
strings_["send_no_balance"] = "No balance";
|
strings_["send_no_balance"] = "No balance";
|
||||||
|
strings_["send_transparent_source_warn"] = "Sending from a transparent address is not private: the sender, amount and recipient are visible on-chain. Use a shielded (Z) address for privacy.";
|
||||||
strings_["send_no_recent"] = "No recent sends";
|
strings_["send_no_recent"] = "No recent sends";
|
||||||
strings_["send_recent_sends"] = "RECENT SENDS";
|
strings_["send_recent_sends"] = "RECENT SENDS";
|
||||||
strings_["send_recipient"] = "RECIPIENT";
|
strings_["send_recipient"] = "RECIPIENT";
|
||||||
@@ -2587,9 +2720,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
||||||
strings_["send_tooltip_select_source"] = "Select a source address first";
|
strings_["send_tooltip_select_source"] = "Select a source address first";
|
||||||
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
||||||
|
strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up.";
|
||||||
|
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
||||||
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
||||||
strings_["send_total"] = "Total";
|
strings_["send_total"] = "Total";
|
||||||
strings_["send_tx_failed"] = "Transaction failed";
|
strings_["send_tx_failed"] = "Transaction failed";
|
||||||
|
strings_["send_maybe_sent"] = "This transaction may already have been broadcast (the reply was lost). Do NOT send again yet - check History first.";
|
||||||
strings_["send_tx_sent"] = "Transaction sent!";
|
strings_["send_tx_sent"] = "Transaction sent!";
|
||||||
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
||||||
strings_["send_tx_success"] = "Transaction sent successfully!";
|
strings_["send_tx_success"] = "Transaction sent successfully!";
|
||||||
@@ -2605,7 +2741,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["shield_check_status"] = "Check Status";
|
strings_["shield_check_status"] = "Check Status";
|
||||||
strings_["shield_completed"] = "Operation completed successfully!";
|
strings_["shield_completed"] = "Operation completed successfully!";
|
||||||
strings_["shield_description"] = "Shield your mining rewards by sending coinbase outputs from transparent addresses to a shielded address. This improves privacy by hiding your mining income.";
|
strings_["shield_description"] = "Shield your mining rewards by sending coinbase outputs from transparent addresses to a shielded address. This improves privacy by hiding your mining income.";
|
||||||
strings_["shield_from_address"] = "From Address:";
|
strings_["shield_from_address"] = "From mining address:";
|
||||||
|
strings_["shield_all_mining_addrs"] = "All mining addresses";
|
||||||
|
strings_["shield_confirm_fmt"] = "Shield up to %s DRGX from %d mining address(es) (at most %d rewards per run) to %s? Network fee: %s DRGX.";
|
||||||
strings_["shield_funds"] = "Shield Funds";
|
strings_["shield_funds"] = "Shield Funds";
|
||||||
strings_["shield_in_progress"] = "Operation in progress...";
|
strings_["shield_in_progress"] = "Operation in progress...";
|
||||||
strings_["shield_max_utxos"] = "Max UTXOs per operation";
|
strings_["shield_max_utxos"] = "Max UTXOs per operation";
|
||||||
@@ -2710,6 +2848,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["validate_invalid"] = "INVALID";
|
strings_["validate_invalid"] = "INVALID";
|
||||||
strings_["validate_is_mine"] = "This wallet owns this address";
|
strings_["validate_is_mine"] = "This wallet owns this address";
|
||||||
strings_["validate_not_mine"] = "Not owned by this wallet";
|
strings_["validate_not_mine"] = "Not owned by this wallet";
|
||||||
|
strings_["validate_ownership_unknown_offline"] = "Unknown — node offline";
|
||||||
|
strings_["validate_ownership_unknown_lite"] = "Unknown — not checkable in the lite wallet";
|
||||||
strings_["validate_ownership"] = "Ownership:";
|
strings_["validate_ownership"] = "Ownership:";
|
||||||
strings_["validate_results"] = "Results:";
|
strings_["validate_results"] = "Results:";
|
||||||
strings_["validate_results_placeholder"] = "Results will appear here";
|
strings_["validate_results_placeholder"] = "Results will appear here";
|
||||||
@@ -2771,6 +2911,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
|
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
|
||||||
strings_["explorer_invalid_response"] = "Invalid response from daemon";
|
strings_["explorer_invalid_response"] = "Invalid response from daemon";
|
||||||
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
|
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
|
||||||
|
strings_["explorer_lookup_failed"] = "Lookup failed";
|
||||||
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
|
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
|
||||||
strings_["explorer_no_results"] = "No matching cached blocks";
|
strings_["explorer_no_results"] = "No matching cached blocks";
|
||||||
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
|
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
|
||||||
@@ -2998,6 +3139,15 @@ const char* I18n::translate(const char* key) const
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string I18n::translateCopy(const char* key) const
|
||||||
|
{
|
||||||
|
if (!key) return std::string();
|
||||||
|
std::shared_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
auto it = strings_.find(key);
|
||||||
|
if (it != strings_.end()) return it->second;
|
||||||
|
return std::string(key);
|
||||||
|
}
|
||||||
|
|
||||||
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
||||||
{
|
{
|
||||||
// Check if already registered
|
// Check if already registered
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <shared_mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -39,6 +41,15 @@ public:
|
|||||||
*/
|
*/
|
||||||
const char* translate(const char* key) const;
|
const char* translate(const char* key) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translate: returns a COPY under a read lock.
|
||||||
|
*
|
||||||
|
* translate() hands back a pointer into the string table, which loadLanguage() rewrites on the
|
||||||
|
* main thread. Any caller off the main thread (e.g. the EmbeddedDaemon monitor thread) MUST use
|
||||||
|
* this instead; main-thread UI code keeps using translate()/TR().
|
||||||
|
*/
|
||||||
|
std::string translateCopy(const char* key) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get current locale
|
* @brief Get current locale
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +77,9 @@ private:
|
|||||||
void overlayTranslations(const nlohmann::json& translations);
|
void overlayTranslations(const nlohmann::json& translations);
|
||||||
|
|
||||||
std::string current_locale_ = "en";
|
std::string current_locale_ = "en";
|
||||||
|
// Guards strings_/current_locale_ against loadLanguage() (main thread, writer) racing
|
||||||
|
// translateCopy() (off-thread readers). The lock-free translate() is main-thread only.
|
||||||
|
mutable std::shared_mutex strings_mutex_;
|
||||||
std::unordered_map<std::string, std::string> strings_;
|
std::unordered_map<std::string, std::string> strings_;
|
||||||
std::vector<std::pair<std::string, std::string>> available_languages_;
|
std::vector<std::pair<std::string, std::string>> available_languages_;
|
||||||
};
|
};
|
||||||
@@ -79,6 +93,13 @@ inline const char* tr(const char* key) {
|
|||||||
return I18n::instance().translate(key);
|
return I18n::instance().translate(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translation returning an owned copy. Use from any non-main thread.
|
||||||
|
*/
|
||||||
|
inline std::string trCopy(const char* key) {
|
||||||
|
return I18n::instance().translateCopy(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,29 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Strictly parse a decimal DRGX amount from an (attacker-influenceable) payment URI / QR: digits with at
|
||||||
|
// most one '.', at most 8 fractional digits, in [0, 21000000]. Rejects empty, NaN/inf, signs, whitespace
|
||||||
|
// and trailing garbage that std::stod would otherwise accept — so a crafted URI can't inject a bogus
|
||||||
|
// amount into the Send form.
|
||||||
|
bool parseStrictAmount(const std::string& s, double& out) {
|
||||||
|
if (s.empty() || s.size() > 32) return false;
|
||||||
|
int dots = 0, digits = 0, frac = 0; bool afterDot = false;
|
||||||
|
for (char c : s) {
|
||||||
|
if (c == '.') { if (++dots > 1) return false; afterDot = true; }
|
||||||
|
else if (c >= '0' && c <= '9') { ++digits; if (afterDot && ++frac > 8) return false; }
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
if (digits == 0) return false;
|
||||||
|
double v = 0.0;
|
||||||
|
try { v = std::stod(s); } catch (...) { return false; }
|
||||||
|
if (!std::isfinite(v) || v < 0.0 || v > 21000000.0) return false;
|
||||||
|
out = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
||||||
|
|
||||||
if (key == "amount" || key == "amt") {
|
if (key == "amount" || key == "amt") {
|
||||||
try {
|
double a = 0.0;
|
||||||
result.amount = std::stod(value);
|
if (!parseStrictAmount(value, a)) {
|
||||||
} catch (...) {
|
result.error = "Invalid amount";
|
||||||
// Invalid amount, keep as 0
|
result.valid = false;
|
||||||
|
return result; // reject the whole URI rather than silently treating it as 0
|
||||||
}
|
}
|
||||||
|
result.amount = a;
|
||||||
} else if (key == "label") {
|
} else if (key == "label") {
|
||||||
result.label = value;
|
result.label = value;
|
||||||
} else if (key == "memo") {
|
} else if (key == "memo") {
|
||||||
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate amount if present
|
// Validate amount if present
|
||||||
if (result.amount < 0) {
|
if (!std::isfinite(result.amount) || result.amount < 0 || result.amount > 21000000.0) {
|
||||||
result.error = "Invalid negative amount";
|
result.error = "Invalid negative amount";
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -25,6 +25,7 @@
|
|||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
#include <psapi.h>
|
#include <psapi.h>
|
||||||
#include <tlhelp32.h>
|
#include <tlhelp32.h>
|
||||||
|
#include <aclapi.h> // SetEntriesInAcl / EXPLICIT_ACCESS — owner-only DACL for restricted writes
|
||||||
#else
|
#else
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -361,15 +362,54 @@ bool Platform::writeFileAtomically(const std::string& path,
|
|||||||
const std::string tmp = path + ".tmp";
|
const std::string tmp = path + ".tmp";
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
// When restrictPermissions, build a PROTECTED owner-only DACL so the secret file (exported keys,
|
||||||
|
// seed backup, vault, chat export) is readable only by the current user — not whatever the parent
|
||||||
|
// dir's inherited ACL grants. Best-effort: on any failure fall back to default ACLs (the per-user
|
||||||
|
// profile dir still protects it). The ACL is set on the temp file; MoveFileEx is a same-volume
|
||||||
|
// rename, so the explicit (non-inherited) DACL carries to the final path.
|
||||||
|
PACL dacl = nullptr;
|
||||||
|
PTOKEN_USER tokenUser = nullptr;
|
||||||
|
SECURITY_DESCRIPTOR sd;
|
||||||
|
SECURITY_ATTRIBUTES saOwner{};
|
||||||
|
SECURITY_ATTRIBUTES* psa = nullptr;
|
||||||
|
if (restrictPermissions) {
|
||||||
|
HANDLE tok = nullptr;
|
||||||
|
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) {
|
||||||
|
DWORD need = 0;
|
||||||
|
GetTokenInformation(tok, TokenUser, nullptr, 0, &need);
|
||||||
|
if (need > 0) {
|
||||||
|
tokenUser = static_cast<PTOKEN_USER>(LocalAlloc(LPTR, need));
|
||||||
|
if (tokenUser && GetTokenInformation(tok, TokenUser, tokenUser, need, &need)) {
|
||||||
|
EXPLICIT_ACCESSA ea{};
|
||||||
|
ea.grfAccessPermissions = GENERIC_ALL;
|
||||||
|
ea.grfAccessMode = SET_ACCESS;
|
||||||
|
ea.grfInheritance = NO_INHERITANCE;
|
||||||
|
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
|
||||||
|
ea.Trustee.TrusteeType = TRUSTEE_IS_USER;
|
||||||
|
ea.Trustee.ptstrName = static_cast<LPSTR>(tokenUser->User.Sid);
|
||||||
|
if (SetEntriesInAclA(1, &ea, nullptr, &dacl) == ERROR_SUCCESS &&
|
||||||
|
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION) &&
|
||||||
|
SetSecurityDescriptorControl(&sd, SE_DACL_PROTECTED, SE_DACL_PROTECTED) &&
|
||||||
|
SetSecurityDescriptorDacl(&sd, TRUE, dacl, FALSE)) {
|
||||||
|
saOwner.nLength = sizeof(saOwner);
|
||||||
|
saOwner.lpSecurityDescriptor = &sd;
|
||||||
|
saOwner.bInheritHandle = FALSE;
|
||||||
|
psa = &saOwner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseHandle(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
|
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
|
||||||
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
|
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
|
||||||
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
|
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
|
||||||
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
|
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
|
||||||
// AV / the indexer had the new file open.
|
// AV / the indexer had the new file open.
|
||||||
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, nullptr,
|
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, psa,
|
||||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
if (h == INVALID_HANDLE_VALUE) return false;
|
if (h == INVALID_HANDLE_VALUE) { if (dacl) LocalFree(dacl); if (tokenUser) LocalFree(tokenUser); return false; }
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
while (written < content.size()) {
|
while (written < content.size()) {
|
||||||
@@ -382,9 +422,12 @@ bool Platform::writeFileAtomically(const std::string& path,
|
|||||||
// Flush the temp file's contents to stable storage before the rename.
|
// Flush the temp file's contents to stable storage before the rename.
|
||||||
if (ok && !FlushFileBuffers(h)) ok = false;
|
if (ok && !FlushFileBuffers(h)) ok = false;
|
||||||
CloseHandle(h);
|
CloseHandle(h);
|
||||||
|
// The DACL is baked into the temp file's security descriptor at CreateFileA time, so release the
|
||||||
|
// builders now; every path below (including the early returns) is past this point.
|
||||||
|
if (dacl) LocalFree(dacl);
|
||||||
|
if (tokenUser) LocalFree(tokenUser);
|
||||||
if (!ok) { DeleteFileA(tmp.c_str()); return false; }
|
if (!ok) { DeleteFileA(tmp.c_str()); return false; }
|
||||||
}
|
}
|
||||||
(void)restrictPermissions; // owner-only ACLs rely on the per-user profile dir on Windows
|
|
||||||
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
|
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
|
||||||
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
|
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
|
||||||
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
|
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
||||||
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
||||||
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
||||||
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
|
if (walletFile.empty() || walletFile == "wallet.dat") {
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::string s;
|
std::string s;
|
||||||
s.reserve(walletFile.size());
|
s.reserve(walletFile.size());
|
||||||
for (unsigned char c : walletFile)
|
for (unsigned char c : walletFile)
|
||||||
@@ -58,11 +62,18 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
char suffix[9];
|
char suffix[9];
|
||||||
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
||||||
if (s.size() > 48) s.resize(48);
|
if (s.size() > 48) s.resize(48);
|
||||||
scope_ = s + "-" + suffix;
|
std::string newScope = s + "-" + suffix;
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_ = std::move(newScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SecureVault::getVaultPath() const {
|
std::string SecureVault::getVaultPath() const {
|
||||||
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
|
std::string scope;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope = scope_;
|
||||||
|
}
|
||||||
|
const std::string name = scope.empty() ? std::string("vault.dat") : ("vault-" + scope + ".dat");
|
||||||
return (fs::path(Platform::getConfigDir()) / name).string();
|
return (fs::path(Platform::getConfigDir()) / name).string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -101,6 +102,9 @@ private:
|
|||||||
const uint8_t* salt, size_t saltLen,
|
const uint8_t* salt, size_t saltLen,
|
||||||
uint8_t* key, size_t keyLen) const;
|
uint8_t* key, size_t keyLen) const;
|
||||||
|
|
||||||
|
// Guards scope_: setWalletScope() runs on the main thread (wallet switch) while retrieve()/hasVault()/
|
||||||
|
// changePin() run on RPC worker threads and read it via getVaultPath().
|
||||||
|
mutable std::mutex scopeMutex_;
|
||||||
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// A tiny, pure block-sync rate estimator for the status-bar / peers-card ETA. Pure (no ImGui, no App):
|
||||||
|
// App owns one instance, feeds it the current height + a monotonic clock once per frame while syncing,
|
||||||
|
// and resets it on connect / disconnect / rescan / restart so a height drop never produces a stale or
|
||||||
|
// absurd ("412h") ETA.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
class SyncRateEstimator {
|
||||||
|
public:
|
||||||
|
void reset() { prevBlocks_ = -1; prevTime_ = 0.0; rate_ = 0.0; }
|
||||||
|
|
||||||
|
// Call once per frame while syncing. `now` is monotonic seconds (e.g. ImGui::GetTime()).
|
||||||
|
void update(int blocks, double now) {
|
||||||
|
// First sample, a rescan/restart height drop, or a clock reset → re-baseline, don't compute.
|
||||||
|
if (prevBlocks_ < 0 || blocks < prevBlocks_ || now < prevTime_) {
|
||||||
|
prevBlocks_ = blocks; prevTime_ = now; rate_ = 0.0; return;
|
||||||
|
}
|
||||||
|
const double dt = now - prevTime_;
|
||||||
|
if (blocks > prevBlocks_) {
|
||||||
|
if (dt > 0.5) {
|
||||||
|
const double raw = (blocks - prevBlocks_) / dt;
|
||||||
|
rate_ = rate_ > 0.0 ? rate_ * 0.7 + raw * 0.3 : raw; // smooth (same alpha as the old code)
|
||||||
|
prevBlocks_ = blocks; prevTime_ = now;
|
||||||
|
}
|
||||||
|
} else if (dt > 10.0) { // stalled: decay and re-baseline the clock
|
||||||
|
rate_ *= 0.5; prevTime_ = now;
|
||||||
|
if (rate_ < 0.1) rate_ = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double rate() const { return rate_; } // blocks/sec, 0 when unknown
|
||||||
|
|
||||||
|
// <= 0 when unknown. Capped at ~1 year so a tiny rate can't overflow.
|
||||||
|
long long etaSeconds(int blocksLeft) const {
|
||||||
|
if (rate_ <= 0.1 || blocksLeft <= 0) return -1;
|
||||||
|
const double s = blocksLeft / rate_;
|
||||||
|
return s > 3.15e7 ? 31500000LL : static_cast<long long>(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
int prevBlocks_ = -1; // -1 = unset (distinct from a legitimate height 0)
|
||||||
|
double prevTime_ = 0.0;
|
||||||
|
double rate_ = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Human ETA with days, e.g. "3d 4h", "5h 12m", "7m 9s", "42s". English unit letters (as the old code).
|
||||||
|
inline void formatEta(long long s, char* out, std::size_t n) {
|
||||||
|
if (s >= 86400) std::snprintf(out, n, "%lldd %lldh", s / 86400, (s % 86400) / 3600);
|
||||||
|
else if (s >= 3600) std::snprintf(out, n, "%lldh %lldm", s / 3600, (s % 3600) / 60);
|
||||||
|
else if (s >= 60) std::snprintf(out, n, "%lldm %llds", s / 60, s % 60);
|
||||||
|
else std::snprintf(out, n, "%llds", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
|||||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||||
: api_(other.api_),
|
: api_(other.api_),
|
||||||
unavailableReason_(std::move(other.unavailableReason_)),
|
unavailableReason_(std::move(other.unavailableReason_)),
|
||||||
shutdownCalled_(other.shutdownCalled_),
|
shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
|
||||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||||
{
|
{
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||||
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
|||||||
shutdown();
|
shutdown();
|
||||||
api_ = other.api_;
|
api_ = other.api_;
|
||||||
unavailableReason_ = std::move(other.unavailableReason_);
|
unavailableReason_ = std::move(other.unavailableReason_);
|
||||||
shutdownCalled_ = other.shutdownCalled_;
|
shutdownCalled_.store(other.shutdownCalled_.load(std::memory_order_acquire), std::memory_order_release);
|
||||||
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||||
std::memory_order_release);
|
std::memory_order_release);
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
bool overwrite)
|
bool overwrite)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNewFromPhrase(
|
return takeOwnedString(api_.initializeNewFromPhrase(
|
||||||
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
||||||
}
|
}
|
||||||
@@ -158,6 +160,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,9 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
if (command.empty()) return {false, {}, "lite command is empty"};
|
||||||
|
// A retired controller's bridge is shut down; litelib_execute would run on the NEW global client
|
||||||
|
// (a stale send/save/sync/console command). Refuse it here, once, for every detached thread.
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||||
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
|
|
||||||
void LiteClientBridge::shutdown()
|
void LiteClientBridge::shutdown()
|
||||||
{
|
{
|
||||||
if (shutdownCalled_) return;
|
if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
|
||||||
shutdownCalled_ = true;
|
|
||||||
if (available()) api_.shutdown();
|
if (available()) api_.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiteBridgeStringResult LiteClientBridge::shutDownResult()
|
||||||
|
{
|
||||||
|
return {false, {}, "lite client was shut down"};
|
||||||
|
}
|
||||||
|
|
||||||
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
||||||
{
|
{
|
||||||
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public:
|
|||||||
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
||||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||||
void shutdown();
|
void shutdown();
|
||||||
|
// True once shutdown() ran (retire() / destruction / move-from). After this every backend call
|
||||||
|
// (execute / initialize*) is refused: litelib_* acts on the process-global client, which may by
|
||||||
|
// then belong to a NEW controller.
|
||||||
|
bool isShutdown() const { return shutdownCalled_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
||||||
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
||||||
@@ -82,12 +86,13 @@ private:
|
|||||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||||
|
|
||||||
LiteBridgeStringResult unavailableResult() const;
|
LiteBridgeStringResult unavailableResult() const;
|
||||||
|
static LiteBridgeStringResult shutDownResult();
|
||||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||||
static bool commandIsReadOnly(const std::string& command);
|
static bool commandIsReadOnly(const std::string& command);
|
||||||
|
|
||||||
LiteClientBridgeApi api_;
|
LiteClientBridgeApi api_;
|
||||||
std::string unavailableReason_;
|
std::string unavailableReason_;
|
||||||
bool shutdownCalled_ = false;
|
std::atomic<bool> shutdownCalled_{false}; // read by detached threads in execute() while retire() sets it
|
||||||
std::atomic<std::uint64_t> mutationEpoch_{0};
|
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ LiteBridgeOwnedString::LiteBridgeOwnedString(LiteBridgeOwnedString&& other) noex
|
|||||||
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
||||||
{
|
{
|
||||||
if (this == &other) return *this;
|
if (this == &other) return *this;
|
||||||
copyValueBeforeFree();
|
// Free only — do NOT copy the raw value here: nothing reads copiedValue_ after this, and the copy
|
||||||
|
// (std::string assignment) can throw bad_alloc, which from this noexcept path would call std::terminate.
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
moveFrom(std::move(other));
|
moveFrom(std::move(other));
|
||||||
@@ -49,7 +50,8 @@ LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&&
|
|||||||
|
|
||||||
LiteBridgeOwnedString::~LiteBridgeOwnedString()
|
LiteBridgeOwnedString::~LiteBridgeOwnedString()
|
||||||
{
|
{
|
||||||
copyValueBeforeFree();
|
// Free only (see operator= note): the copy is unnecessary when the object is dying and must never
|
||||||
|
// throw out of a destructor. The copy happens solely in intoResult(), the one path that reads it.
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
}
|
}
|
||||||
@@ -66,13 +68,16 @@ LiteBridgeStringResult LiteBridgeOwnedString::intoResult()
|
|||||||
return {false, {}, "lite bridge freeString function is unavailable"};
|
return {false, {}, "lite bridge freeString function is unavailable"};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyValueBeforeFree() can throw bad_alloc; if it does, rawValue_ is still set and the destructor
|
||||||
|
// (now free-only) releases it — no leak, no re-copy, no terminate.
|
||||||
copyValueBeforeFree();
|
copyValueBeforeFree();
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
|
|
||||||
const std::string resultValue = copiedValue_;
|
// Move the copy into the result instead of making a second const copy that would linger unwiped.
|
||||||
|
std::string resultValue = std::move(copiedValue_);
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
||||||
return {true, resultValue, {}};
|
return {true, std::move(resultValue), {}};
|
||||||
}
|
}
|
||||||
|
|
||||||
void LiteBridgeOwnedString::copyValueBeforeFree()
|
void LiteBridgeOwnedString::copyValueBeforeFree()
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 21,000,000 DRGX in zatoshi: no single backend-reported amount can legitimately exceed the supply.
|
||||||
|
constexpr std::int64_t kMaxMoneyZat = static_cast<std::int64_t>(21000000) * 100000000LL;
|
||||||
|
|
||||||
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +98,9 @@ bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
|||||||
if (value.is_string()) {
|
if (value.is_string()) {
|
||||||
const auto text = value.get<std::string>();
|
const auto text = value.get<std::string>();
|
||||||
if (text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
|
// stoull follows strtoull: it accepts a leading '-' (wrapping to a huge value), '+' and
|
||||||
|
// whitespace. Require a leading digit so a hostile/buggy backend can't forge a balance.
|
||||||
|
if (!std::isdigit(static_cast<unsigned char>(text[0]))) return false;
|
||||||
std::size_t parsedSize = 0;
|
std::size_t parsedSize = 0;
|
||||||
const auto parsed = std::stoull(text, &parsedSize, 10);
|
const auto parsed = std::stoull(text, &parsedSize, 10);
|
||||||
if (parsedSize != text.size()) return false;
|
if (parsedSize != text.size()) return false;
|
||||||
@@ -125,6 +131,8 @@ bool jsonToSigned(const json& value, std::int64_t& output)
|
|||||||
if (value.is_string()) {
|
if (value.is_string()) {
|
||||||
const auto text = value.get<std::string>();
|
const auto text = value.get<std::string>();
|
||||||
if (text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
|
// Reject leading whitespace / '+' that strtoll would otherwise accept (a '-' is valid here).
|
||||||
|
if (text[0] != '-' && !std::isdigit(static_cast<unsigned char>(text[0]))) return false;
|
||||||
std::size_t parsedSize = 0;
|
std::size_t parsedSize = 0;
|
||||||
const auto parsed = std::stoll(text, &parsedSize, 10);
|
const auto parsed = std::stoll(text, &parsedSize, 10);
|
||||||
if (parsedSize != text.size()) return false;
|
if (parsedSize != text.size()) return false;
|
||||||
@@ -277,9 +285,20 @@ bool parseSpendableOutput(const json& value,
|
|||||||
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
||||||
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
||||||
|
if (output.value > static_cast<std::uint64_t>(kMaxMoneyZat)) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".value", "note value exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
||||||
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
||||||
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
||||||
|
// The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor
|
||||||
|
// height) is authoritative when present (Sapling notes only; transparent utxos carry no such field,
|
||||||
|
// so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability.
|
||||||
|
bool backendSpendable = true;
|
||||||
|
if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false;
|
||||||
|
if (value.contains("spendable") && value.at("spendable").is_boolean()) {
|
||||||
|
output.spendable = output.spendable && backendSpendable;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,15 +356,37 @@ bool parseTransactionRecord(const json& value,
|
|||||||
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
||||||
LiteTransactionOutput parsedOutput;
|
LiteTransactionOutput parsedOutput;
|
||||||
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
||||||
|
// Reject negative values and guard the running sum: signed overflow is UB, and a hostile
|
||||||
|
// backend could otherwise overflow the total into a bogus (even negative) amount.
|
||||||
|
const std::string outputPath = path + ".outgoing_metadata[" + std::to_string(index) + "]";
|
||||||
|
if (parsedOutput.value < 0 || parsedOutput.value > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, outputPath + ".value", "outgoing output value is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
|
if (output.amount > kMaxMoneyZat - parsedOutput.value) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".outgoing_metadata", "outgoing output total exceeds the maximum supply");
|
||||||
|
}
|
||||||
output.amount += parsedOutput.value;
|
output.amount += parsedOutput.value;
|
||||||
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
||||||
}
|
}
|
||||||
|
// A send this wallet did not author (seed restore / other device) has empty outgoing_metadata,
|
||||||
|
// so the sum is 0. Fall back to the record's net 'amount' magnitude (includes the fee) so Sent
|
||||||
|
// totals aren't understated. Display-only; tolerant of a missing/odd field.
|
||||||
|
if (output.amount == 0 && value.contains("amount") && !value.at("amount").is_null()) {
|
||||||
|
std::int64_t net = 0;
|
||||||
|
if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits<std::int64_t>::min()
|
||||||
|
&& (net < 0 ? -net : net) <= kMaxMoneyZat) {
|
||||||
|
output.amount = net < 0 ? -net : net;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
output.direction = LiteTransactionDirection::Receive;
|
output.direction = LiteTransactionDirection::Receive;
|
||||||
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
||||||
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
||||||
|
if (output.amount < 0 || output.amount > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".amount", "received amount is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -561,11 +602,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
|||||||
|
|
||||||
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
||||||
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
||||||
std::string syncing = "false";
|
std::optional<std::string> syncingField;
|
||||||
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
|
if (!readOptionalStringField(value, "syncing", syncingField, result)) return result;
|
||||||
|
const std::string syncing = syncingField.value_or(std::string());
|
||||||
const bool isSyncing = (syncing == "true" || syncing == "1");
|
const bool isSyncing = (syncing == "true" || syncing == "1");
|
||||||
|
const bool isIdle = (syncing == "false" || syncing == "0");
|
||||||
|
|
||||||
if (isSyncing) {
|
if (!isSyncing && !isIdle) {
|
||||||
|
// Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced.
|
||||||
|
result.syncStatus.syncedBlocks = 0;
|
||||||
|
result.syncStatus.totalBlocks = 0;
|
||||||
|
result.syncStatus.complete = false;
|
||||||
|
result.syncStatus.indeterminate = true;
|
||||||
|
result.syncStatus.progress = 0.0;
|
||||||
|
} else if (isSyncing) {
|
||||||
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
||||||
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
||||||
result.syncStatus.complete = false; // actively syncing
|
result.syncStatus.complete = false; // actively syncing
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
|
|||||||
std::uint64_t totalBlocks = 0;
|
std::uint64_t totalBlocks = 0;
|
||||||
double progress = 0.0;
|
double progress = 0.0;
|
||||||
bool complete = false;
|
bool complete = false;
|
||||||
|
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
|
||||||
|
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
|
||||||
|
// the save proceeds as it always did.
|
||||||
|
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
|
||||||
|
{
|
||||||
|
const auto r = bridge.execute("encryptionstatus", "");
|
||||||
|
if (!r.ok) return false;
|
||||||
|
try {
|
||||||
|
const auto j = nlohmann::json::parse(r.value);
|
||||||
|
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
||||||
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
||||||
// every attempt. Captures shared state only.
|
// every attempt. Captures shared state only.
|
||||||
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
|
|||||||
op.error = call.error.empty() ? "sync failed" : call.error;
|
op.error = call.error.empty() ? "sync failed" : call.error;
|
||||||
}
|
}
|
||||||
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
||||||
if (!bridge->execute("save", "").ok) // W5-2
|
// Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
|
||||||
|
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
|
||||||
|
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
|
||||||
|
if (liteWalletEncryptedAndUnlocked(*bridge))
|
||||||
|
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
|
||||||
|
else if (!bridge->execute("save", "").ok) // W5-2
|
||||||
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
||||||
if (op.ok) {
|
if (op.ok) {
|
||||||
scan->setError(std::string());
|
scan->setError(std::string());
|
||||||
@@ -358,13 +378,38 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
||||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
||||||
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
||||||
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
|
// Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
|
||||||
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
|
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
|
||||||
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
|
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
|
||||||
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
|
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
|
||||||
state.spendablePrivateBalance = state.privateBalance;
|
{
|
||||||
state.spendableTransparentBalance = state.transparentBalance;
|
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
|
||||||
state.spendableTotalBalance = state.totalBalance;
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis);
|
||||||
|
state.unconfirmedBalance = static_cast<double>(
|
||||||
|
std::max(model.balance.unconfirmedZatoshis, notYetSpendable)) / kZatoshisPerCoin;
|
||||||
|
}
|
||||||
|
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
|
||||||
|
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
|
||||||
|
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
|
||||||
|
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
|
||||||
|
const double spendableShielded = static_cast<double>(
|
||||||
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
||||||
|
state.spendablePrivateBalance = spendableShielded;
|
||||||
|
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
|
||||||
|
// an unconfirmed tx of ours is still in transparentBalance. Subtract those from the SPENDABLE figure only
|
||||||
|
// (clamped; gross transparentBalance display unchanged). Needs this cycle's utxo list: if the notes call
|
||||||
|
// failed, nothing is subtracted (same as before).
|
||||||
|
std::uint64_t unconfirmedSpentTransparent = 0;
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
for (const auto& output : model.spendableOutputs) {
|
||||||
|
if (output.kind == LiteSpendableOutputKind::Utxo && output.unconfirmedSpent && !output.spent)
|
||||||
|
unconfirmedSpentTransparent += output.valueZatoshis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::uint64_t spendableTransparentZat = model.balance.transparentZatoshis -
|
||||||
|
std::min(unconfirmedSpentTransparent, model.balance.transparentZatoshis);
|
||||||
|
state.spendableTransparentBalance = static_cast<double>(spendableTransparentZat) / kZatoshisPerCoin;
|
||||||
|
state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.hasAddresses) {
|
if (model.hasAddresses) {
|
||||||
@@ -373,10 +418,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
||||||
// the per-address figures match the confirmed balance the wallet treats as available.
|
// the per-address figures match the confirmed balance the wallet treats as available.
|
||||||
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
||||||
|
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
||||||
if (model.hasSpendableOutputs) {
|
if (model.hasSpendableOutputs) {
|
||||||
for (const auto& output : model.spendableOutputs) {
|
for (const auto& output : model.spendableOutputs) {
|
||||||
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
||||||
perAddressZatoshis[output.address] += output.valueZatoshis;
|
perAddressZatoshis[output.address] += output.valueZatoshis;
|
||||||
|
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,8 +432,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
||||||
// like fund loss and breaks "send from this address".
|
// like fund loss and breaks "send from this address".
|
||||||
std::unordered_map<std::string, double> priorBalances;
|
std::unordered_map<std::string, double> priorBalances;
|
||||||
|
std::unordered_map<std::string, double> priorSpendable;
|
||||||
|
std::unordered_map<std::string, bool> priorHasKey;
|
||||||
if (!model.hasSpendableOutputs) {
|
if (!model.hasSpendableOutputs) {
|
||||||
for (const auto& a : state.addresses) priorBalances[a.address] = a.balance;
|
for (const auto& a : state.addresses) {
|
||||||
|
priorBalances[a.address] = a.balance;
|
||||||
|
priorSpendable[a.address] = a.spendableBalance;
|
||||||
|
priorHasKey[a.address] = a.has_spending_key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.addresses.clear();
|
state.addresses.clear();
|
||||||
@@ -404,9 +457,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
||||||
}
|
}
|
||||||
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
|
// balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
|
||||||
|
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
const auto sit = perAddressSpendableZatoshis.find(addr.address);
|
||||||
|
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
|
||||||
|
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
|
||||||
|
} else {
|
||||||
|
const auto psit = priorSpendable.find(addr.address);
|
||||||
|
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
|
||||||
|
}
|
||||||
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
||||||
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
||||||
|
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
|
||||||
|
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
|
||||||
|
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
|
||||||
|
}
|
||||||
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
||||||
state.z_addresses.push_back(info);
|
state.z_addresses.push_back(info);
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +498,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
||||||
}
|
}
|
||||||
tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin;
|
// Signed like the full node: a send is a negative debit, a receive positive. Consumers
|
||||||
|
// (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs.
|
||||||
|
tx.amount = static_cast<double>(record.signedAmountZatoshis) / kZatoshisPerCoin;
|
||||||
tx.timestamp = record.timestamp;
|
tx.timestamp = record.timestamp;
|
||||||
tx.address = record.address;
|
tx.address = record.address;
|
||||||
tx.memo = record.memo;
|
tx.memo = record.memo;
|
||||||
@@ -912,9 +980,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
|
|||||||
if (op == PendingSecurityOp::Encrypt) {
|
if (op == PendingSecurityOp::Encrypt) {
|
||||||
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||||
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
||||||
if (!enc.ok)
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
|
||||||
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
||||||
enc.error);
|
enc.error);
|
||||||
|
}
|
||||||
} else if (op == PendingSecurityOp::Unlock) {
|
} else if (op == PendingSecurityOp::Unlock) {
|
||||||
if (!unlockWallet(pendingLifecyclePassphrase_))
|
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||||
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||||
@@ -1044,7 +1114,12 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
||||||
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
||||||
// surface it as the synced height/tip so the block count is correct at rest.
|
// surface it as the synced height/tip so the block count is correct at rest.
|
||||||
if (bridge_ && (!honorStop || running_.load())) {
|
// Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
|
||||||
|
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
|
||||||
|
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
|
||||||
|
(syncResult.syncStatus.indeterminate ||
|
||||||
|
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
|
||||||
|
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
|
||||||
const auto h = bridge_->execute("height", "");
|
const auto h = bridge_->execute("height", "");
|
||||||
if (h.ok) {
|
if (h.ok) {
|
||||||
const auto parsed = parseLiteHeightResponse(h.value);
|
const auto parsed = parseLiteHeightResponse(h.value);
|
||||||
@@ -1068,7 +1143,14 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
model.sync.progress = std::min(model.sync.progress, 0.999);
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
|
||||||
|
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
|
||||||
|
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
|
||||||
|
model.sync.complete = false;
|
||||||
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
|
}
|
||||||
model.syncError = scan_->errorCopy();
|
model.syncError = scan_->errorCopy();
|
||||||
|
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1317,14 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
|
|||||||
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse
|
||||||
|
// copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable).
|
||||||
|
static void wipeJsonStrings(nlohmann::json& j)
|
||||||
|
{
|
||||||
|
if (j.is_string()) { secureWipeLiteSecret(j.get_ref<nlohmann::json::string_t&>()); }
|
||||||
|
else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); }
|
||||||
|
}
|
||||||
|
|
||||||
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
||||||
{
|
{
|
||||||
LiteExportResult out;
|
LiteExportResult out;
|
||||||
@@ -1243,13 +1333,18 @@ LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& opti
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
// Empty address -> export keys for all addresses; otherwise just the given address.
|
// Empty address -> export keys for all addresses; otherwise just the given address.
|
||||||
const auto result = bridge_->execute("export", optionalAddress);
|
auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
out.error = result.error.empty() ? "export failed" : result.error;
|
out.error = result.error.empty() ? "export failed" : result.error;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
nlohmann::json j;
|
||||||
|
// result.value + the parse tree hold the private keys; wipe them on every return path. out already
|
||||||
|
// has its own copy (out.privateKeysJson) which the caller wipes after use.
|
||||||
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
||||||
|
scrub{result.value, j};
|
||||||
try {
|
try {
|
||||||
const auto j = nlohmann::json::parse(result.value);
|
j = nlohmann::json::parse(result.value);
|
||||||
if (j.is_object() && j.contains("error")) {
|
if (j.is_object() && j.contains("error")) {
|
||||||
out.error = extractJsonError(j);
|
out.error = extractJsonError(j);
|
||||||
return out;
|
return out;
|
||||||
@@ -1270,13 +1365,18 @@ LiteSeedResult LiteWalletController::exportSeed()
|
|||||||
out.error = "no wallet is open";
|
out.error = "no wallet is open";
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
const auto result = bridge_->execute("seed", "");
|
auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
out.error = result.error.empty() ? "seed export failed" : result.error;
|
out.error = result.error.empty() ? "seed export failed" : result.error;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
nlohmann::json j;
|
||||||
|
// result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase
|
||||||
|
// is extracted separately and returned to the caller).
|
||||||
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
||||||
|
scrub{result.value, j};
|
||||||
try {
|
try {
|
||||||
const auto j = nlohmann::json::parse(result.value);
|
j = nlohmann::json::parse(result.value);
|
||||||
if (j.is_object()) {
|
if (j.is_object()) {
|
||||||
if (j.contains("error")) {
|
if (j.contains("error")) {
|
||||||
out.error = extractJsonError(j);
|
out.error = extractJsonError(j);
|
||||||
@@ -1541,6 +1641,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LiteWalletController::takeEncryptionFailureNotice()
|
||||||
|
{
|
||||||
|
const bool failed = lifecycleEncryptFailed_;
|
||||||
|
lifecycleEncryptFailed_ = false;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
bool LiteWalletController::lockWallet()
|
bool LiteWalletController::lockWallet()
|
||||||
{
|
{
|
||||||
if (!walletOpen_.load() || !bridge_) return false;
|
if (!walletOpen_.load() || !bridge_) return false;
|
||||||
@@ -1589,6 +1696,7 @@ void LiteWalletController::workerLoop()
|
|||||||
{
|
{
|
||||||
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
||||||
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
||||||
|
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
|
||||||
while (running_.load()) {
|
while (running_.load()) {
|
||||||
if (walletOpen_.load()) {
|
if (walletOpen_.load()) {
|
||||||
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
||||||
@@ -1600,6 +1708,19 @@ void LiteWalletController::workerLoop()
|
|||||||
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
||||||
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
||||||
failStreak += 1;
|
failStreak += 1;
|
||||||
|
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
|
||||||
|
// server was gone. Re-publish it demoted, with the reason.
|
||||||
|
if (lastModel && running_.load()) {
|
||||||
|
auto demoted = *lastModel;
|
||||||
|
if (demoted.hasSyncStatus) {
|
||||||
|
demoted.sync.complete = false;
|
||||||
|
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
|
||||||
|
}
|
||||||
|
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
|
||||||
|
lastModel = demoted;
|
||||||
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
|
pendingModel_ = std::move(demoted);
|
||||||
|
}
|
||||||
} else { // DoFull
|
} else { // DoFull
|
||||||
// Sample the change baselines BEFORE the data commands (race rule).
|
// Sample the change baselines BEFORE the data commands (race rule).
|
||||||
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
||||||
@@ -1617,6 +1738,7 @@ void LiteWalletController::workerLoop()
|
|||||||
gate.encrypted = m.encrypted;
|
gate.encrypted = m.encrypted;
|
||||||
gate.locked = m.locked;
|
gate.locked = m.locked;
|
||||||
gate.fullAt = std::chrono::steady_clock::now();
|
gate.fullAt = std::chrono::steady_clock::now();
|
||||||
|
lastModel = m;
|
||||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
pendingModel_ = std::move(model);
|
pendingModel_ = std::move(model);
|
||||||
} else {
|
} else {
|
||||||
@@ -1696,7 +1818,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
|
|||||||
// copy and wipes it.
|
// copy and wipes it.
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet created but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
return result;
|
return result;
|
||||||
@@ -1726,7 +1851,10 @@ LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreR
|
|||||||
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet restored but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.seedPhrase);
|
secureWipeLiteSecret(request.seedPhrase);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ public:
|
|||||||
bool lockWallet();
|
bool lockWallet();
|
||||||
LiteEncryptionResult decryptWallet(std::string passphrase);
|
LiteEncryptionResult decryptWallet(std::string passphrase);
|
||||||
LiteEncryptionStatus encryptionStatus();
|
LiteEncryptionStatus encryptionStatus();
|
||||||
|
// True once (then cleared) if the passphrase encryption of a just-created/restored wallet FAILED, leaving it
|
||||||
|
// unencrypted. Main thread; the App turns it into a visible error instead of a log line.
|
||||||
|
bool takeEncryptionFailureNotice();
|
||||||
|
|
||||||
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
||||||
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
||||||
@@ -488,6 +491,7 @@ private:
|
|||||||
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||||
|
bool lifecycleEncryptFailed_ = false; // main-thread only; see takeEncryptionFailureNotice()
|
||||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||||
|
|||||||
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
|
|||||||
result.model.sync.complete = bundle.syncStatus.complete;
|
result.model.sync.complete = bundle.syncStatus.complete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-address spending-key evidence from the backend's per-note 'spendable' flag. Only POSITIVE evidence
|
||||||
|
// sets spendabilityKnown: a spendable note proves a key; a confirmed, long-mature (well past any anchor
|
||||||
|
// offset) unspent note that is still not spendable can only mean a view-only address. Fresh/immature
|
||||||
|
// notes, no notes, or no chain height stay unknown (the controller then keeps its spendable default).
|
||||||
|
if (result.model.hasAddresses && result.model.hasSpendableOutputs) {
|
||||||
|
constexpr std::int64_t kMatureDepth = 100;
|
||||||
|
const std::int64_t tip = result.model.hasSyncStatus
|
||||||
|
? static_cast<std::int64_t>(result.model.sync.chainHeight) : 0;
|
||||||
|
for (auto& addr : result.model.addresses) {
|
||||||
|
if (addr.kind != LiteWalletAppAddressKind::Shielded) continue;
|
||||||
|
bool anySpendable = false;
|
||||||
|
bool matureNonSpendable = false;
|
||||||
|
for (const auto& out : result.model.spendableOutputs) {
|
||||||
|
if (out.address != addr.address || out.kind != LiteSpendableOutputKind::UnspentNote) continue;
|
||||||
|
if (out.spent || out.unconfirmedSpent || out.pending) continue;
|
||||||
|
if (out.spendable) { anySpendable = true; break; }
|
||||||
|
if (tip > 0 && out.createdInBlock && *out.createdInBlock > 0 &&
|
||||||
|
tip - *out.createdInBlock >= kMatureDepth) {
|
||||||
|
matureNonSpendable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anySpendable) { addr.spendabilityKnown = true; addr.spendable = true; }
|
||||||
|
else if (matureNonSpendable) { addr.spendabilityKnown = true; addr.spendable = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!modelHasAnyMappedField(result.model)) {
|
if (!modelHasAnyMappedField(result.model)) {
|
||||||
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
||||||
result.error = result.issues.back().message;
|
result.error = result.issues.back().message;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a
|
|||||||
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
||||||
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
||||||
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
||||||
|
inline std::atomic<bool> g_liteFakeEncryptFails{false}; // "encrypt" returns an error and changes no state
|
||||||
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
||||||
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
||||||
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
||||||
@@ -261,6 +262,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
|||||||
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
||||||
if (std::strcmp(c, "encrypt") == 0) {
|
if (std::strcmp(c, "encrypt") == 0) {
|
||||||
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
||||||
|
if (g_liteFakeEncryptFails.load())
|
||||||
|
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
|
||||||
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
||||||
return liteFakeDup("{\"result\":\"success\"}");
|
return liteFakeDup("{\"result\":\"success\"}");
|
||||||
}
|
}
|
||||||
|
|||||||
+595
-6
@@ -819,6 +819,41 @@ void testClassifyRpcFailure()
|
|||||||
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
|
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lost-reply sends: only a transport failure AFTER the request left (no HTTP status) is ambiguous.
|
||||||
|
void testSendMaybeInFlight()
|
||||||
|
{
|
||||||
|
using namespace dragonx::rpc;
|
||||||
|
using K = RpcFailureKind;
|
||||||
|
// Ambiguous: reply timeout, connection cut mid-reply, our own abort - request sent, no HTTP status.
|
||||||
|
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::ReplyTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, true)));
|
||||||
|
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Other, "t", CURLE_GOT_NOTHING, 0, true)));
|
||||||
|
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Aborted, "t", CURLE_ABORTED_BY_CALLBACK, 0, true)));
|
||||||
|
// Definitive: never reached the daemon, or the daemon/HTTP layer answered.
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ConnectTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, false)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::NotListening, "t", CURLE_COULDNT_CONNECT, 0, false)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::QueueFull, "t", CURLE_OK, 500, true)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ShuttingDown, "t", CURLE_OK, 503, true)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Auth, "t", CURLE_OK, 401, true)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Deferred, "t", CURLE_OK, 0, false)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-6, "Insufficient funds")));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-5, "Invalid address")));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(std::runtime_error("plain")));
|
||||||
|
// Lite text heuristic.
|
||||||
|
EXPECT_TRUE(liteSendErrorMayBeInFlight("status: Unknown, message: \"transport error\""));
|
||||||
|
EXPECT_TRUE(liteSendErrorMayBeInFlight("operation timed out"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("error trying to connect: tcp connect error"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("Error: SendResponse { error_code: 1, error_message: \"bad-txns\" }"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("Insufficient verified funds"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("Wallet is locked"));
|
||||||
|
// Marker round trip + idempotence.
|
||||||
|
const std::string marked = markSendMaybeInFlight("RPC request failed: Timeout");
|
||||||
|
EXPECT_TRUE(isSendMaybeInFlight(marked));
|
||||||
|
EXPECT_FALSE(isSendMaybeInFlight("RPC request failed: Timeout"));
|
||||||
|
EXPECT_EQ(markSendMaybeInFlight(marked), marked);
|
||||||
|
EXPECT_EQ(stripSendMaybeInFlight(marked), std::string("RPC request failed: Timeout"));
|
||||||
|
EXPECT_EQ(stripSendMaybeInFlight("plain"), std::string("plain"));
|
||||||
|
}
|
||||||
|
|
||||||
void testSecureWipeAndRemove()
|
void testSecureWipeAndRemove()
|
||||||
{
|
{
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
@@ -1033,7 +1068,17 @@ void testPaymentUri()
|
|||||||
|
|
||||||
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
||||||
EXPECT_FALSE(invalid.valid);
|
EXPECT_FALSE(invalid.valid);
|
||||||
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
EXPECT_EQ(invalid.error, std::string("Invalid amount")); // strict parse rejects '-' up front now
|
||||||
|
|
||||||
|
// Crafted amounts that std::stod would have accepted are now rejected before the Send form.
|
||||||
|
for (const char* bad : {"nan", "inf", "1e3", "0x10", "1.5abc", "0.000000001", "21000001", ""}) {
|
||||||
|
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + bad);
|
||||||
|
EXPECT_FALSE(r.valid);
|
||||||
|
}
|
||||||
|
for (const char* ok : {"1", "0.00000001", "21000000", "1.5"}) {
|
||||||
|
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + ok);
|
||||||
|
EXPECT_TRUE(r.valid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void testWalletFileProbe()
|
void testWalletFileProbe()
|
||||||
@@ -1198,10 +1243,35 @@ void testWalletFileProbe()
|
|||||||
|
|
||||||
void testAmountFormatting()
|
void testAmountFormatting()
|
||||||
{
|
{
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
using namespace dragonx::util;
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
|
EXPECT_EQ(formatAmountFixed(1.0), std::string("1.00000000"));
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
|
EXPECT_EQ(formatAmountFixed(0.1), std::string("0.10000000"));
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
|
EXPECT_EQ(formatAmountFixed(0.000000019), std::string("0.00000002"));
|
||||||
|
EXPECT_EQ(formatAmountFixed(12.3456, 2), std::string("12.35"));
|
||||||
|
// No scientific notation / comma decimals for tiny or large amounts (the z_sendmany string).
|
||||||
|
EXPECT_EQ(formatAmountFixed(1e-8), std::string("0.00000001"));
|
||||||
|
EXPECT_EQ(formatAmountFixed(21000000.0), std::string("21000000.00000000"));
|
||||||
|
EXPECT_TRUE(formatAmountFixed(0.00000001).find('e') == std::string::npos);
|
||||||
|
|
||||||
|
// toSats / canAfford — the exact-max send that double addition wrongly blocked (fund-correctness audit).
|
||||||
|
EXPECT_EQ(toSats(63.0410039), 6304100390LL);
|
||||||
|
EXPECT_TRUE(canAfford(63.0409039, 0.0001, 63.0410039)); // true total == available; was false in doubles
|
||||||
|
EXPECT_TRUE(canAfford(44.83756352, 0.0001, 44.83766352));
|
||||||
|
EXPECT_TRUE(canAfford(0.4999, 0.0001, 0.5));
|
||||||
|
EXPECT_TRUE(canAfford(0.5, 0.0, 0.5)); // exact, no fee
|
||||||
|
EXPECT_TRUE(!canAfford(0.5, 0.0001, 0.5)); // amount+fee over available
|
||||||
|
EXPECT_TRUE(!canAfford(0.50001, 0.0, 0.5));
|
||||||
|
|
||||||
|
// coinsToZatoshis — rejects the values that would wrap/round wrong on the lite send path.
|
||||||
|
std::uint64_t z = 0;
|
||||||
|
EXPECT_TRUE(coinsToZatoshis(1.0, z) && z == 100000000ULL);
|
||||||
|
EXPECT_TRUE(coinsToZatoshis(0.00000001, z) && z == 1ULL);
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(0.0, z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(-1.0, z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(0.000000004, z)); // sub-zatoshi rounds to 0 → rejected
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits<double>::quiet_NaN(), z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits<double>::infinity(), z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(21000001.0, z)); // over supply cap
|
||||||
}
|
}
|
||||||
|
|
||||||
void testSpendableFiltering()
|
void testSpendableFiltering()
|
||||||
@@ -2940,6 +3010,88 @@ void testNetworkRefreshResultModels()
|
|||||||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||||||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||||||
EXPECT_EQ(confirmedBlock, 222);
|
EXPECT_EQ(confirmedBlock, 222);
|
||||||
|
|
||||||
|
// Tier-B: per-address balances accumulate in integer satoshis (no IEEE drift over many notes).
|
||||||
|
{
|
||||||
|
std::vector<dragonx::AddressInfo> addrs(1);
|
||||||
|
addrs[0].address = "zs1drift";
|
||||||
|
json notes = json::array();
|
||||||
|
for (int i = 0; i < 10; ++i)
|
||||||
|
notes.push_back(json{{"address", "zs1drift"}, {"amount", 0.1}, {"confirmations", 3}});
|
||||||
|
Refresh::applyShieldedBalancesFromUnspent(addrs, notes);
|
||||||
|
EXPECT_TRUE(addrs[0].balance == 1.0); // exact; double summation would give 0.9999999999999999
|
||||||
|
EXPECT_TRUE(addrs[0].spendableBalance == 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier-B: readBalanceString rejects anything but a plain decimal (std::stod accepted trailing garbage).
|
||||||
|
{
|
||||||
|
auto bad = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", "1.5abc"}, {"transparent", ""}, {"total", "nan"}}, json(), true, json::object(), false);
|
||||||
|
EXPECT_TRUE(!bad.shieldedBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad.transparentBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad.totalBalance.has_value());
|
||||||
|
auto bad2 = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", " 1.0"}, {"transparent", "0x10"}, {"total", "1e3"}}, json(), true, json::object(), false);
|
||||||
|
EXPECT_TRUE(!bad2.shieldedBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad2.transparentBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad2.totalBalance.has_value());
|
||||||
|
auto good = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, json(), true, json::object(), false);
|
||||||
|
EXPECT_NEAR(*good.shieldedBalance, 1.25, 1e-9);
|
||||||
|
EXPECT_NEAR(*good.totalBalance, 1.75, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resilience mediums: the send tip-gate, cached view-tx confirmations advancing, and the dropped /
|
||||||
|
// stale-pending display predicates.
|
||||||
|
void testResilienceMediums()
|
||||||
|
{
|
||||||
|
{ // 1) send tip gate
|
||||||
|
dragonx::SyncInfo s;
|
||||||
|
s.blocks = 100; s.headers = 100; s.syncing = false; s.tip_known = false;
|
||||||
|
EXPECT_FALSE(s.sendTipReady(false)); // peerless / fresh connect: tip unknown
|
||||||
|
EXPECT_TRUE(s.sendTipReady(true)); // lite exempt
|
||||||
|
s.tip_known = true;
|
||||||
|
EXPECT_TRUE(s.sendTipReady(false));
|
||||||
|
s.syncing = true;
|
||||||
|
EXPECT_FALSE(s.sendTipReady(false));
|
||||||
|
s.syncing = false; s.blocks = 0;
|
||||||
|
EXPECT_FALSE(s.sendTipReady(false));
|
||||||
|
}
|
||||||
|
{ // 2) cached view entry confirmations advance (never backwards; pending not extrapolated)
|
||||||
|
using R = dragonx::services::NetworkRefreshService;
|
||||||
|
R::TransactionViewCacheEntry e;
|
||||||
|
e.from_address = "zs-from"; e.confirmations = 3; e.timestamp = 500; e.observed_height = 100;
|
||||||
|
R::TransactionViewCacheEntry::Output o; o.address = "zs-dest"; o.value = 1.0; e.outgoing_outputs.push_back(o);
|
||||||
|
std::vector<dragonx::TransactionInfo> rows;
|
||||||
|
R::appendViewTransactionOutputs(rows, "tx1", e, 105);
|
||||||
|
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(rows[0].confirmations, 8); // 3 + (105-100)
|
||||||
|
std::vector<dragonx::TransactionInfo> rows2;
|
||||||
|
R::appendViewTransactionOutputs(rows2, "tx1", e); // no tip -> unchanged
|
||||||
|
EXPECT_EQ(rows2[0].confirmations, 3);
|
||||||
|
std::vector<dragonx::TransactionInfo> rows3;
|
||||||
|
R::appendViewTransactionOutputs(rows3, "tx1", e, 90); // reorg/older tip: never backwards
|
||||||
|
EXPECT_EQ(rows3[0].confirmations, 3);
|
||||||
|
e.confirmations = 0; // pending entries are not extrapolated
|
||||||
|
std::vector<dragonx::TransactionInfo> rows4;
|
||||||
|
R::appendViewTransactionOutputs(rows4, "tx1", e, 110);
|
||||||
|
EXPECT_EQ(rows4[0].confirmations, 0);
|
||||||
|
}
|
||||||
|
{ // 3) dropped / stale-pending predicates
|
||||||
|
dragonx::TransactionInfo t;
|
||||||
|
t.confirmations = -1;
|
||||||
|
EXPECT_TRUE(t.isConflicted());
|
||||||
|
EXPECT_FALSE(t.isStalePending(100000));
|
||||||
|
t.confirmations = 0; t.timestamp = 1000;
|
||||||
|
EXPECT_FALSE(t.isConflicted());
|
||||||
|
EXPECT_FALSE(t.isStalePending(1000 + 3600)); // exactly the limit: still pending
|
||||||
|
EXPECT_TRUE(t.isStalePending(1000 + 3601));
|
||||||
|
t.timestamp = 0;
|
||||||
|
EXPECT_FALSE(t.isStalePending(999999)); // unknown time never flagged
|
||||||
|
t.timestamp = 1000; t.confirmations = 1;
|
||||||
|
EXPECT_FALSE(t.isStalePending(999999)); // mined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void testOperationStatusPollParsing()
|
void testOperationStatusPollParsing()
|
||||||
@@ -3559,6 +3711,29 @@ void testNodeStatusBanner()
|
|||||||
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
|
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
|
||||||
EXPECT_TRUE(s.detail.empty());
|
EXPECT_TRUE(s.detail.empty());
|
||||||
}
|
}
|
||||||
|
// Auth failure (bad rpcuser/rpcpassword): embedded → Restart (regenerates creds), external → Open
|
||||||
|
// config folder. A hard error, detail cleared (App renders a translated actionable hint).
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.auth_failed = true;
|
||||||
|
in.using_embedded_daemon = true; in.has_daemon_controller = true;
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::AuthFailed);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Error);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::RestartNode);
|
||||||
|
EXPECT_TRUE(s.detail.empty());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.auth_failed = true; // external node
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::AuthFailed);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::OpenConfigFolder);
|
||||||
|
}
|
||||||
|
// A connected wallet never shows the auth banner (the connected branch returns earlier).
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||||
|
in.auth_failed = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
// Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker).
|
// Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker).
|
||||||
{
|
{
|
||||||
NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0;
|
NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0;
|
||||||
@@ -3633,6 +3808,33 @@ void testNodeStatusBanner()
|
|||||||
NodeBannerInputs open; open.lite = true; open.connected = true;
|
NodeBannerInputs open; open.lite = true; open.connected = true;
|
||||||
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
|
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
|
||||||
}
|
}
|
||||||
|
// Legacy-funds-stuck: a connected+synced degraded wallet with stuck transparent funds offers recovery.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||||
|
in.legacy_funds_stuck = true;
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::LegacyFundsStuck);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::RecoverLegacyFunds);
|
||||||
|
}
|
||||||
|
// A functional wallet (legacy_funds_stuck false — e.g. non-mnemonic but can still shield) is NEVER flagged.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
|
// NoPeers takes priority: can't recover before the node can see the chain.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.connected = true; in.tip_known = false; in.blocks = 100; in.legacy_funds_stuck = true;
|
||||||
|
EXPECT_TRUE(evaluateNodeStatusBanner(in).reason == NodeBannerReason::NoPeers);
|
||||||
|
}
|
||||||
|
// Lite never shows it (the lite branch returns first; App also gates the input to full-node).
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.lite = true; in.connected = true; in.legacy_funds_stuck = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void testStalenessBadge()
|
void testStalenessBadge()
|
||||||
@@ -3753,6 +3955,24 @@ void testSeedMigrationResume()
|
|||||||
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
|
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
|
||||||
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
|
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
|
||||||
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
|
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
|
||||||
|
|
||||||
|
// FIX E: reorg-safe adopt depth + the final pre-stop re-verification verdict.
|
||||||
|
EXPECT_EQ(dragonx::kSweepAdoptMinConfs, 10);
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(-1));
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(0));
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(1));
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(9));
|
||||||
|
EXPECT_TRUE(dragonx::sweepConfsAreReorgSafe(10));
|
||||||
|
const double kDust = 0.0001;
|
||||||
|
EXPECT_TRUE(dragonx::adoptReverifyPasses(true, 10, 0.0, false, kDust)); // deep + empty + synced
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 9, 0.0, false, kDust)); // 1-block-reorg window
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, -1, 0.0, false, kDust)); // conflicted
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 0.0, true, kDust)); // syncing -> balance untrusted
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 5.0, false, kDust)); // funds remain / unconfirmed
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, -1.0, false, kDust)); // balance unknown
|
||||||
|
EXPECT_TRUE(dragonx::adoptReverifyPasses(false, 0, 0.0, false, kDust)); // genuinely empty, no sweep
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 2.0, false, kDust)); // no-funds path but unconfirmed funds
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 0.0, true, kDust)); // no-funds path while syncing
|
||||||
}
|
}
|
||||||
|
|
||||||
void testLoggerFileSink()
|
void testLoggerFileSink()
|
||||||
@@ -8962,6 +9182,26 @@ void testExplorerBlockCache()
|
|||||||
EXPECT_TRUE(cache.storeBlock(block12));
|
EXPECT_TRUE(cache.storeBlock(block12));
|
||||||
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
|
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
|
||||||
|
|
||||||
|
{ // loadRangeCached memoizes on revision(): reads don't bump; a store invalidates; updates are seen.
|
||||||
|
// Isolated on its own cache so storing blocks here can't perturb the shared `cache`'s count assertions.
|
||||||
|
fs::path mdir = makeTempDir();
|
||||||
|
ExplorerBlockCache mcache((mdir / "memo.sqlite").string(), (mdir / "memo.json").string());
|
||||||
|
EXPECT_TRUE(mcache.ensureOpen());
|
||||||
|
ExplorerBlockSummary m12; m12.height = 12; m12.hash = "m-12"; m12.tx_count = 5; m12.size = 1200; m12.time = 5200; m12.difficulty = 1.4;
|
||||||
|
EXPECT_TRUE(mcache.storeBlock(m12));
|
||||||
|
const std::uint64_t rev0 = mcache.revision();
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(12, 12).size(), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(mcache.revision(), rev0); // reading does not bump
|
||||||
|
ExplorerBlockSummary m13 = m12; m13.height = 13; m13.hash = "m-13";
|
||||||
|
EXPECT_TRUE(mcache.storeBlock(m13));
|
||||||
|
EXPECT_TRUE(mcache.revision() != rev0); // a store invalidates the memo
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2));
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2)); // memo hit, same answer
|
||||||
|
m13.tx_count = 99;
|
||||||
|
EXPECT_TRUE(mcache.storeBlock(m13));
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(13, 13).at(13).tx_count, 99); // sees the update, not a stale memo
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
||||||
EXPECT_TRUE(reopened.ensureOpen());
|
EXPECT_TRUE(reopened.ensureOpen());
|
||||||
@@ -10863,6 +11103,66 @@ void testLiteWalletControllerCreateEncryptsWithPassphrase()
|
|||||||
EXPECT_FALSE(s2.locked);
|
EXPECT_FALSE(s2.locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX C: (e) a create-time passphrase whose encryption FAILS is surfaced once via takeEncryptionFailureNotice() and
|
||||||
|
// leaves the wallet unencrypted; (d) the background scan save is skipped while an encrypted wallet is unlocked (the
|
||||||
|
// backend save would re-lock it) and resumes once locked.
|
||||||
|
void testLiteWalletControllerLockUnlockParity()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||||||
|
const auto conn = defaultLiteConnectionSettings();
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
{ // (e)
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
dragonx::test::g_liteFakeEncryptFails = true;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletCreateRequest req;
|
||||||
|
req.passphrase = "hunter2hunter2";
|
||||||
|
(void)c->createWallet(req);
|
||||||
|
EXPECT_TRUE(c->takeEncryptionFailureNotice());
|
||||||
|
EXPECT_FALSE(c->takeEncryptionFailureNotice()); // one-shot
|
||||||
|
EXPECT_FALSE(c->encryptionStatus().encrypted);
|
||||||
|
dragonx::test::g_liteFakeEncryptFails = false;
|
||||||
|
}
|
||||||
|
{ // (e) success path raises no notice
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletCreateRequest req;
|
||||||
|
req.passphrase = "hunter2hunter2";
|
||||||
|
(void)c->createWallet(req);
|
||||||
|
EXPECT_FALSE(c->takeEncryptionFailureNotice());
|
||||||
|
EXPECT_TRUE(c->encryptionStatus().encrypted);
|
||||||
|
}
|
||||||
|
{ // (d)
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady);
|
||||||
|
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
EXPECT_TRUE(c->encryptWallet("pw12345678").ok);
|
||||||
|
EXPECT_TRUE(c->unlockWallet("pw12345678"));
|
||||||
|
EXPECT_FALSE(c->encryptionStatus().locked);
|
||||||
|
const long saves0 = dragonx::test::g_liteFakeSaveCount.load();
|
||||||
|
EXPECT_TRUE(c->startRescan());
|
||||||
|
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
EXPECT_TRUE(c->syncComplete());
|
||||||
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), saves0); // no background save while unlocked
|
||||||
|
EXPECT_FALSE(c->encryptionStatus().locked); // so the unlock survived the scan
|
||||||
|
EXPECT_TRUE(c->lockWallet());
|
||||||
|
EXPECT_TRUE(c->startRescan());
|
||||||
|
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() > saves0); // saves resume once locked
|
||||||
|
}
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
void testLiteWalletControllerAsyncLifecycleFailover()
|
void testLiteWalletControllerAsyncLifecycleFailover()
|
||||||
{
|
{
|
||||||
using namespace dragonx::wallet;
|
using namespace dragonx::wallet;
|
||||||
@@ -11193,6 +11493,71 @@ void testSameTransactionList()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tx_structure_revision: a pure confirmation-count bump moves tx_revision only; any other change, a pending<->confirmed
|
||||||
|
// flip, or a size change moves both; legacy writers (replace/touch) always move both.
|
||||||
|
void testTxStructureRevision()
|
||||||
|
{
|
||||||
|
auto row = [](const char* txid, const char* type, double amount, std::int64_t ts, int conf) {
|
||||||
|
dragonx::TransactionInfo t;
|
||||||
|
t.txid = txid; t.type = type; t.amount = amount; t.timestamp = ts; t.confirmations = conf;
|
||||||
|
t.address = "zs-a"; t.from_address = ""; t.memo = "m";
|
||||||
|
return t;
|
||||||
|
};
|
||||||
|
const std::vector<dragonx::TransactionInfo> base = {
|
||||||
|
row("tx-a", "receive", 1.5, 1000, 12),
|
||||||
|
row("tx-b", "send", -0.25, 1001, 3),
|
||||||
|
row("tx-c", "mined", 3.0, 1002, 120),
|
||||||
|
};
|
||||||
|
EXPECT_TRUE(dragonx::sameTransactionLayout(base[0], base[0]));
|
||||||
|
{ // confirmation count only: tx_revision +1, structure unchanged, new count stored
|
||||||
|
dragonx::WalletState state;
|
||||||
|
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
|
||||||
|
const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision;
|
||||||
|
auto next = base;
|
||||||
|
next[0].confirmations += 1;
|
||||||
|
next[1].confirmations += 5;
|
||||||
|
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next)));
|
||||||
|
EXPECT_EQ(state.tx_revision, rev + 1);
|
||||||
|
EXPECT_EQ(state.tx_structure_revision, srev);
|
||||||
|
EXPECT_EQ(state.transactions[1].confirmations, 8);
|
||||||
|
}
|
||||||
|
{ // pending (0) -> confirmed (1) flips the sort state: structural
|
||||||
|
dragonx::WalletState state;
|
||||||
|
auto pend = base;
|
||||||
|
pend[1].confirmations = 0;
|
||||||
|
state.replaceTransactions(std::move(pend));
|
||||||
|
const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision;
|
||||||
|
EXPECT_TRUE(state.setTransactionsIfChanged(std::vector<dragonx::TransactionInfo>(base)));
|
||||||
|
EXPECT_EQ(state.tx_revision, rev + 1);
|
||||||
|
EXPECT_EQ(state.tx_structure_revision, srev + 1);
|
||||||
|
}
|
||||||
|
{ // a non-confirmation field change, and a size change, are structural
|
||||||
|
dragonx::WalletState state;
|
||||||
|
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
|
||||||
|
std::uint64_t srev = state.tx_structure_revision;
|
||||||
|
auto next = base;
|
||||||
|
next[2].memo = "changed";
|
||||||
|
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next)));
|
||||||
|
EXPECT_EQ(state.tx_structure_revision, srev + 1);
|
||||||
|
srev = state.tx_structure_revision;
|
||||||
|
auto shorter = base;
|
||||||
|
shorter.pop_back();
|
||||||
|
EXPECT_TRUE(state.setTransactionsIfChanged(std::move(shorter)));
|
||||||
|
EXPECT_EQ(state.tx_structure_revision, srev + 1);
|
||||||
|
}
|
||||||
|
{ // identical list: neither counter moves; legacy touch moves both
|
||||||
|
dragonx::WalletState state;
|
||||||
|
state.replaceTransactions(std::vector<dragonx::TransactionInfo>(base));
|
||||||
|
const std::uint64_t rev = state.tx_revision, srev = state.tx_structure_revision;
|
||||||
|
EXPECT_FALSE(state.setTransactionsIfChanged(std::vector<dragonx::TransactionInfo>(base)));
|
||||||
|
EXPECT_EQ(state.tx_revision, rev);
|
||||||
|
EXPECT_EQ(state.tx_structure_revision, srev);
|
||||||
|
state.touchTransactions();
|
||||||
|
EXPECT_EQ(state.tx_revision, rev + 1);
|
||||||
|
EXPECT_EQ(state.tx_structure_revision, srev + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void testHistoryTxidGroups()
|
void testHistoryTxidGroups()
|
||||||
{
|
{
|
||||||
using dragonx::ui::history::groupByTxid;
|
using dragonx::ui::history::groupByTxid;
|
||||||
@@ -11486,8 +11851,9 @@ void testLiteRefreshModelAppliesToWalletState()
|
|||||||
|
|
||||||
bundle.hasBalance = true;
|
bundle.hasBalance = true;
|
||||||
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
||||||
|
bundle.balance.spendableShieldedBalance = 200000000; // synced: all of it verified/spendable (required field)
|
||||||
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
||||||
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX
|
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX (mempool-incoming)
|
||||||
|
|
||||||
bundle.hasAddresses = true;
|
bundle.hasAddresses = true;
|
||||||
bundle.addresses.zAddresses = {"zs1shielded"};
|
bundle.addresses.zAddresses = {"zs1shielded"};
|
||||||
@@ -11643,6 +12009,120 @@ void testLiteSendShowsRecipientFromOutgoing()
|
|||||||
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lite sends must carry a NEGATIVE amount (full-node debit semantics) so History/Details/CSV render a
|
||||||
|
// red "-" instead of a green "+"; receives stay positive.
|
||||||
|
void testLiteSendAmountIsNegative()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
LiteWalletRefreshBundle bundle;
|
||||||
|
bundle.complete = true;
|
||||||
|
bundle.hasTransactions = true;
|
||||||
|
LiteTransactionRecord snd;
|
||||||
|
snd.txid = "s"; snd.datetime = 1700000000; snd.direction = LiteTransactionDirection::Send;
|
||||||
|
LiteTransactionOutput out; out.address = "zs1r"; out.value = 75000000;
|
||||||
|
snd.outgoingMetadata.push_back(out);
|
||||||
|
snd.amount = 75000000;
|
||||||
|
LiteTransactionRecord rcv;
|
||||||
|
rcv.txid = "r"; rcv.datetime = 1700000100; rcv.direction = LiteTransactionDirection::Receive;
|
||||||
|
rcv.address = "zs1me"; rcv.amount = 50000000;
|
||||||
|
bundle.transactions.transactions.push_back(snd);
|
||||||
|
bundle.transactions.transactions.push_back(rcv);
|
||||||
|
dragonx::WalletState state;
|
||||||
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
|
||||||
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 2);
|
||||||
|
for (const auto& t : state.transactions) {
|
||||||
|
if (t.txid == "s") { EXPECT_EQ(t.type, std::string("send")); EXPECT_NEAR(t.amount, -0.75, 1e-9); }
|
||||||
|
else { EXPECT_EQ(t.type, std::string("receive")); EXPECT_NEAR(t.amount, 0.5, 1e-9); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A send with empty outgoing_metadata (seed restore / other device) falls back to |record.amount|.
|
||||||
|
void testLiteForeignSendFallsBackToRecordAmount()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto parsed = parseLiteTransactionsResponse(std::string(
|
||||||
|
R"([{"txid":"f","datetime":1700000000,"outgoing_metadata":[],"amount":-123456789}])"));
|
||||||
|
EXPECT_TRUE(parsed.ok);
|
||||||
|
EXPECT_EQ(static_cast<int>(parsed.transactions.transactions.size()), 1);
|
||||||
|
EXPECT_TRUE(parsed.transactions.transactions[0].direction == LiteTransactionDirection::Send);
|
||||||
|
EXPECT_EQ(parsed.transactions.transactions[0].amount, static_cast<std::int64_t>(123456789));
|
||||||
|
// Explicit outgoing_metadata still wins over the record amount.
|
||||||
|
const auto own = parseLiteTransactionsResponse(std::string(
|
||||||
|
R"([{"txid":"o","datetime":1,"outgoing_metadata":[{"address":"zs1","value":5}],"amount":-99}])"));
|
||||||
|
EXPECT_TRUE(own.ok);
|
||||||
|
EXPECT_EQ(own.transactions.transactions[0].amount, static_cast<std::int64_t>(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier-B: the parser range-checks amounts (reject negative / over-supply) and fails with a reason instead
|
||||||
|
// of a silent bare `return false` that rejected the whole list with an empty error.
|
||||||
|
void testLiteParserAmountRangeChecks()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
// Negative receive amount rejected with a reason.
|
||||||
|
auto neg = parseLiteTransactionsResponse(std::string(R"([{"txid":"a","datetime":1,"address":"zs1","amount":-5}])"));
|
||||||
|
EXPECT_TRUE(!neg.ok);
|
||||||
|
EXPECT_TRUE(!neg.errorMessage.empty());
|
||||||
|
// Absurd receive amount (> 21M DRGX) rejected.
|
||||||
|
auto big = parseLiteTransactionsResponse(std::string(R"([{"txid":"b","datetime":1,"address":"zs1","amount":2100000000000001}])"));
|
||||||
|
EXPECT_TRUE(!big.ok);
|
||||||
|
// Max supply itself is accepted.
|
||||||
|
auto edge = parseLiteTransactionsResponse(std::string(R"([{"txid":"c","datetime":1,"address":"zs1","amount":2100000000000000}])"));
|
||||||
|
EXPECT_TRUE(edge.ok);
|
||||||
|
// A negative outgoing output now fails with a message instead of a silent reject.
|
||||||
|
auto sneg = parseLiteTransactionsResponse(std::string(R"([{"txid":"d","datetime":1,"outgoing_metadata":[{"address":"zs1","value":-1}]}])"));
|
||||||
|
EXPECT_TRUE(!sneg.ok);
|
||||||
|
EXPECT_TRUE(!sneg.errorMessage.empty());
|
||||||
|
// Sum of outputs over the cap is rejected.
|
||||||
|
auto sover = parseLiteTransactionsResponse(std::string(R"([{"txid":"e","datetime":1,"outgoing_metadata":[{"address":"a","value":2000000000000000},{"address":"b","value":2000000000000000}]}])"));
|
||||||
|
EXPECT_TRUE(!sover.ok);
|
||||||
|
EXPECT_TRUE(!sover.errorMessage.empty());
|
||||||
|
// Absurd note value rejected.
|
||||||
|
auto note = parseLiteNotesResponse(std::string(R"({"unspent_notes":[{"address":"zs1","created_in_txid":"t","value":9000000000000000}],"utxos":[],"pending_notes":[],"pending_utxos":[]})"));
|
||||||
|
EXPECT_TRUE(!note.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lite-parity mediums: unconfirmedBalance uses the full-node pending semantics (total - spendable, or the
|
||||||
|
// backend mempool-incoming, whichever is larger), and a t-UTXO spent by a pending tx is excluded from the
|
||||||
|
// SPENDABLE transparent total (never over-reports spendable).
|
||||||
|
void testLiteUnconfirmedAndTransparentSpendable()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
{
|
||||||
|
LiteWalletAppRefreshModel m;
|
||||||
|
m.hasBalance = true;
|
||||||
|
m.balance.shieldedZatoshis = 500000000; m.balance.spendableShieldedZatoshis = 300000000;
|
||||||
|
m.balance.transparentZatoshis = 100000000; m.balance.totalZatoshis = 600000000;
|
||||||
|
m.balance.unconfirmedZatoshis = 50000000;
|
||||||
|
dragonx::WalletState s;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.unconfirmedBalance, 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(s.totalBalance - s.spendableTotalBalance, 2.0, 1e-9);
|
||||||
|
m.balance.unconfirmedZatoshis = 900000000;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.unconfirmedBalance, 9.0, 1e-9);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
LiteWalletAppRefreshModel m;
|
||||||
|
m.hasBalance = true;
|
||||||
|
m.balance.transparentZatoshis = 300000000; m.balance.totalZatoshis = 300000000;
|
||||||
|
m.hasSpendableOutputs = true;
|
||||||
|
LiteWalletAppSpendableOutputModel a; a.kind = LiteSpendableOutputKind::Utxo; a.address = "R1"; a.valueZatoshis = 100000000; a.unconfirmedSpent = true;
|
||||||
|
LiteWalletAppSpendableOutputModel b = a; b.unconfirmedSpent = false; b.valueZatoshis = 200000000;
|
||||||
|
m.spendableOutputs = {a, b};
|
||||||
|
dragonx::WalletState s;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.transparentBalance, 3.0, 1e-9);
|
||||||
|
EXPECT_NEAR(s.spendableTransparentBalance, 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(s.spendableTotalBalance, 2.0, 1e-9);
|
||||||
|
m.hasSpendableOutputs = false;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.spendableTransparentBalance, 3.0, 1e-9);
|
||||||
|
m.hasSpendableOutputs = true; m.spendableOutputs[0].valueZatoshis = 900000000;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.spendableTransparentBalance, 0.0, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
||||||
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
||||||
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
||||||
@@ -11679,6 +12159,67 @@ void testLitePartialRefreshKeepsPriorAddressBalances()
|
|||||||
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lite per-address spendable: the backend's per-note 'spendable' flag drives spendableBalance (balance stays
|
||||||
|
// gross), a mature non-spendable note marks the address view-only, and a partial refresh keeps both.
|
||||||
|
void testLitePerAddressSpendableAndViewOnly()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
|
||||||
|
const auto parsed = parseLiteNotesResponse(std::string(R"({
|
||||||
|
"unspent_notes":[
|
||||||
|
{"address":"zsKey","created_in_txid":"a","created_in_block":100,"value":100000000,"spendable":true},
|
||||||
|
{"address":"zsKey","created_in_txid":"b","created_in_block":995,"value":50000000,"spendable":false},
|
||||||
|
{"address":"zsView","created_in_txid":"c","created_in_block":100,"value":200000000,"spendable":false},
|
||||||
|
{"address":"zsFresh","created_in_txid":"d","created_in_block":999,"value":30000000,"spendable":false}],
|
||||||
|
"utxos":[{"address":"t1x","created_in_txid":"e","created_in_block":100,"value":70000000}],
|
||||||
|
"pending_notes":[],"pending_utxos":[]})"));
|
||||||
|
EXPECT_TRUE(parsed.ok);
|
||||||
|
if (!parsed.ok) return;
|
||||||
|
EXPECT_TRUE(parsed.notes.unspentNotes[0].spendable);
|
||||||
|
EXPECT_FALSE(parsed.notes.unspentNotes[1].spendable);
|
||||||
|
EXPECT_TRUE(parsed.notes.utxos[0].spendable); // no flag on utxos -> confirmed/unspent rule
|
||||||
|
|
||||||
|
LiteWalletRefreshBundle bundle;
|
||||||
|
bundle.complete = true;
|
||||||
|
bundle.hasAddresses = true;
|
||||||
|
bundle.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
|
||||||
|
bundle.addresses.tAddresses = {"t1x"};
|
||||||
|
bundle.hasNotes = true;
|
||||||
|
bundle.notes = parsed.notes;
|
||||||
|
bundle.hasSyncStatus = true;
|
||||||
|
bundle.syncStatus.syncedBlocks = 1000;
|
||||||
|
bundle.syncStatus.totalBlocks = 1000;
|
||||||
|
bundle.syncStatus.complete = true;
|
||||||
|
|
||||||
|
dragonx::WalletState state;
|
||||||
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
|
||||||
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 3);
|
||||||
|
if (state.z_addresses.size() != 3) return;
|
||||||
|
const auto& key = state.z_addresses[0];
|
||||||
|
const auto& view = state.z_addresses[1];
|
||||||
|
const auto& fresh = state.z_addresses[2];
|
||||||
|
EXPECT_NEAR(key.balance, 1.5, 1e-9); // gross
|
||||||
|
EXPECT_NEAR(key.spendableBalance, 1.0, 1e-9); // only the anchored note
|
||||||
|
EXPECT_TRUE(key.has_spending_key);
|
||||||
|
EXPECT_NEAR(view.balance, 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(view.spendableBalance, 0.0, 1e-9);
|
||||||
|
EXPECT_FALSE(view.has_spending_key); // mature + non-spendable => view-only
|
||||||
|
EXPECT_FALSE(view.isSpendable());
|
||||||
|
EXPECT_TRUE(fresh.has_spending_key); // immature only => unknown, not mislabelled
|
||||||
|
EXPECT_NEAR(fresh.spendableBalance, 0.0, 1e-9);
|
||||||
|
EXPECT_NEAR(state.t_addresses[0].spendableBalance, 0.7, 1e-9);
|
||||||
|
|
||||||
|
// Partial refresh (notes failed): keep last-known balance, spendable and view-only verdict.
|
||||||
|
LiteWalletRefreshBundle partial;
|
||||||
|
partial.complete = true;
|
||||||
|
partial.hasAddresses = true;
|
||||||
|
partial.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
|
||||||
|
partial.addresses.tAddresses = {"t1x"};
|
||||||
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
|
||||||
|
EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.0, 1e-9);
|
||||||
|
EXPECT_FALSE(state.z_addresses[1].has_spending_key);
|
||||||
|
}
|
||||||
|
|
||||||
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
||||||
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
||||||
void testLiteWalletControllerRefreshPopulatesState()
|
void testLiteWalletControllerRefreshPopulatesState()
|
||||||
@@ -11743,6 +12284,24 @@ void testLiteSyncStatusParserRealShapes()
|
|||||||
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
||||||
EXPECT_FALSE(p.ok);
|
EXPECT_FALSE(p.ok);
|
||||||
}
|
}
|
||||||
|
// Missing or unrecognised "syncing" must NOT default to fully synced.
|
||||||
|
{
|
||||||
|
const auto p = parseLiteSyncStatusResponse(std::string("{}"));
|
||||||
|
EXPECT_TRUE(p.ok);
|
||||||
|
EXPECT_FALSE(p.syncStatus.complete);
|
||||||
|
EXPECT_TRUE(p.syncStatus.indeterminate);
|
||||||
|
EXPECT_NEAR(p.syncStatus.progress, 0.0, 1e-9);
|
||||||
|
const auto q = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"maybe\"}"));
|
||||||
|
EXPECT_TRUE(q.ok);
|
||||||
|
EXPECT_FALSE(q.syncStatus.complete);
|
||||||
|
EXPECT_TRUE(q.syncStatus.indeterminate);
|
||||||
|
}
|
||||||
|
// Explicit idle is still caught-up and determinate.
|
||||||
|
{
|
||||||
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
|
||||||
|
EXPECT_TRUE(p.syncStatus.complete);
|
||||||
|
EXPECT_FALSE(p.syncStatus.indeterminate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
||||||
@@ -11953,6 +12512,26 @@ void testLiteClientBridgeMutationEpoch()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A retired bridge is shut down; every later backend call must be refused (litelib_* would otherwise run
|
||||||
|
// on the process-global client, which may belong to a NEW controller by then). shutdown() is idempotent.
|
||||||
|
void testLiteClientBridgeRefusesAfterShutdown()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
|
||||||
|
EXPECT_FALSE(bridge.isShutdown());
|
||||||
|
EXPECT_TRUE(bridge.execute("balance", "").ok);
|
||||||
|
bridge.shutdown();
|
||||||
|
bridge.shutdown(); // idempotent (exchange): second call is a no-op
|
||||||
|
EXPECT_TRUE(bridge.isShutdown());
|
||||||
|
const auto before = bridge.mutationEpoch();
|
||||||
|
const auto s = bridge.execute("send", "[]");
|
||||||
|
EXPECT_FALSE(s.ok);
|
||||||
|
EXPECT_FALSE(bridge.execute("save", "").ok);
|
||||||
|
EXPECT_FALSE(bridge.initializeExisting(false, "https://x").ok);
|
||||||
|
EXPECT_FALSE(bridge.initializeNew(false, "https://x").ok);
|
||||||
|
EXPECT_TRUE(bridge.mutationEpoch() == before); // a refused send never reached the backend or bumped the epoch
|
||||||
|
}
|
||||||
|
|
||||||
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
|
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
|
||||||
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
||||||
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
||||||
@@ -19077,6 +19656,7 @@ int main()
|
|||||||
{
|
{
|
||||||
testConnectionConfig();
|
testConnectionConfig();
|
||||||
testClassifyRpcFailure();
|
testClassifyRpcFailure();
|
||||||
|
testSendMaybeInFlight();
|
||||||
testClassifyUnlockFailure();
|
testClassifyUnlockFailure();
|
||||||
testHttpSinkAccept();
|
testHttpSinkAccept();
|
||||||
testReadFileExact();
|
testReadFileExact();
|
||||||
@@ -19288,18 +19868,25 @@ int main()
|
|||||||
testLiteWalletControllerM5Persistence();
|
testLiteWalletControllerM5Persistence();
|
||||||
testLiteWalletControllerEncryption();
|
testLiteWalletControllerEncryption();
|
||||||
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||||||
|
testLiteWalletControllerLockUnlockParity();
|
||||||
testLiteWalletControllerAsyncAppliesPassphrase();
|
testLiteWalletControllerAsyncAppliesPassphrase();
|
||||||
testLiteNewPassphraseRules();
|
testLiteNewPassphraseRules();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
|
testLiteUnconfirmedAndTransparentSpendable();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
testLiteTransactionsAreNewestFirst();
|
testLiteTransactionsAreNewestFirst();
|
||||||
testSameTransactionList();
|
testSameTransactionList();
|
||||||
|
testTxStructureRevision();
|
||||||
testHistoryTxidGroups();
|
testHistoryTxidGroups();
|
||||||
testHistoryFilterInputs();
|
testHistoryFilterInputs();
|
||||||
testWalletStateTxRevision();
|
testWalletStateTxRevision();
|
||||||
testTransactionRefreshRevision();
|
testTransactionRefreshRevision();
|
||||||
testLiteSendShowsRecipientFromOutgoing();
|
testLiteSendShowsRecipientFromOutgoing();
|
||||||
|
testLiteSendAmountIsNegative();
|
||||||
|
testLiteForeignSendFallsBackToRecordAmount();
|
||||||
|
testLiteParserAmountRangeChecks();
|
||||||
testLitePartialRefreshKeepsPriorAddressBalances();
|
testLitePartialRefreshKeepsPriorAddressBalances();
|
||||||
|
testLitePerAddressSpendableAndViewOnly();
|
||||||
testLitePerAddressBalances();
|
testLitePerAddressBalances();
|
||||||
testLiteWalletControllerNewAddress();
|
testLiteWalletControllerNewAddress();
|
||||||
testLiteSyncStatusParserRealShapes();
|
testLiteSyncStatusParserRealShapes();
|
||||||
@@ -19308,6 +19895,7 @@ int main()
|
|||||||
testLiteWalletControllerWorkerProducesModel();
|
testLiteWalletControllerWorkerProducesModel();
|
||||||
testLiteWalletControllerWorkerChangeGate();
|
testLiteWalletControllerWorkerChangeGate();
|
||||||
testLiteClientBridgeMutationEpoch();
|
testLiteClientBridgeMutationEpoch();
|
||||||
|
testLiteClientBridgeRefusesAfterShutdown();
|
||||||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||||||
testSerialJobLane();
|
testSerialJobLane();
|
||||||
testLiteGatewayRefreshPolicyHelpers();
|
testLiteGatewayRefreshPolicyHelpers();
|
||||||
@@ -19402,6 +19990,7 @@ int main()
|
|||||||
testResolveExchangeSelection();
|
testResolveExchangeSelection();
|
||||||
testTransactionCsv();
|
testTransactionCsv();
|
||||||
testEarningsSeries();
|
testEarningsSeries();
|
||||||
|
testResilienceMediums();
|
||||||
|
|
||||||
if (g_failures != 0) {
|
if (g_failures != 0) {
|
||||||
std::cerr << g_failures << " test assertion(s) failed\n";
|
std::cerr << g_failures << " test assertion(s) failed\n";
|
||||||
|
|||||||
+47
-6
@@ -2023,9 +2023,19 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
||||||
// new branch. Only a block that spends the note (scan_block) or a reload clears
|
// new branch. Only a block that spends the note (scan_block), the send expiring
|
||||||
// it, the same as for a send that expires unmined.
|
// unmined (cleanup_mempool) or a reload clears it.
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// Same for transparent utxos: one whose spender was reorged out is unspent again
|
||||||
|
// (else it stays spent=Some(removed txid) and drops out of the balance until a rescan).
|
||||||
|
wtx.utxos.iter_mut()
|
||||||
|
.for_each(|utxo| {
|
||||||
|
if utxo.spent.is_some() && txids_to_remove.contains(&utxo.spent.unwrap()) {
|
||||||
|
utxo.spent = None;
|
||||||
|
}
|
||||||
|
// unconfirmed_spent is left as it is, for the same reason as on the notes.
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3007,16 +3017,47 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
// 2. The Tx has been added to the wallet via a mined block
|
// 2. The Tx has been added to the wallet via a mined block
|
||||||
pub fn cleanup_mempool(&self) {
|
pub fn cleanup_mempool(&self) {
|
||||||
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
||||||
|
// A send is built with Builder::new(tip) (wtx.block = tip), so its expiry_height is tip + 20 and it can
|
||||||
|
// still be mined through block tip+20 (consensus rejects it only once the height exceeds the expiry). It
|
||||||
|
// is provably dead once the chain reaches tip+21, so the pending lock on its inputs must be held until
|
||||||
|
// then — one block longer than the old drop point (tip+20), which would release the notes while the tx
|
||||||
|
// could still be mined. The point is that the lock IS released now; previously unconfirmed_spent never was.
|
||||||
|
const MEMPOOL_KEEP: i32 = DEFAULT_TX_EXPIRY_DELTA + 1;
|
||||||
|
|
||||||
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
||||||
|
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
{
|
{
|
||||||
// Remove all expired Txns
|
// Remove all expired Txns, remembering which ones so their inputs can be released below.
|
||||||
self.mempool_txs.write().unwrap().retain( | _, wtx| {
|
self.mempool_txs.write().unwrap().retain( | txid, wtx| {
|
||||||
current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA)
|
let keep = current_height < (wtx.block + MEMPOOL_KEEP);
|
||||||
|
if !keep {
|
||||||
|
expired.insert(txid.clone());
|
||||||
|
}
|
||||||
|
keep
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !expired.is_empty() {
|
||||||
|
// An expired send can never be mined: release the notes/utxos it locked (unconfirmed_spent). The
|
||||||
|
// mempool_txs lock above is released first, so mempool_txs and txs are never held together.
|
||||||
|
let mut txs = self.txs.write().unwrap();
|
||||||
|
// A txid already in txs was mined; scan_block/scan_full_tx settled its inputs, so skip it.
|
||||||
|
expired.retain(|txid| !txs.contains_key(txid));
|
||||||
|
for wtx in txs.values_mut() {
|
||||||
|
for nd in wtx.notes.iter_mut() {
|
||||||
|
if nd.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
nd.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for utxo in wtx.utxos.iter_mut() {
|
||||||
|
if utxo.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
utxo.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// Remove all txns where the txid is added to the wallet directly
|
// Remove all txns where the txid is added to the wallet directly
|
||||||
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ pub struct SaplingNoteData {
|
|||||||
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
||||||
pub memo: Option<Memo>,
|
pub memo: Option<Memo>,
|
||||||
pub is_change: bool,
|
pub is_change: bool,
|
||||||
// TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -987,7 +987,28 @@ impl Sim {
|
|||||||
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
||||||
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
||||||
let rel = &self.rel;
|
let rel = &self.rel;
|
||||||
self.mempool.retain(|t, b| !ids.contains(t) && !rel.contains_key(t) && h < *b + TX_EXPIRY_DELTA);
|
// Mirror cleanup_mempool: it keeps a send for MEMPOOL_KEEP (= TX_EXPIRY_DELTA + 1, i.e. one block past the
|
||||||
|
// tx's expiry height, when it is provably dead) past the sending tip, and for a send dropped purely by
|
||||||
|
// expiry (never mined, i.e. not in `ids`/`rel`/the wallet's `txs`) it now RELEASES the pending lock on the
|
||||||
|
// inputs. So clear `pending` for those, exactly as the wallet clears `unconfirmed_spent`. The real tx
|
||||||
|
// expiry stays TX_EXPIRY_DELTA (used at verify_tx below).
|
||||||
|
const MEMPOOL_KEEP: i32 = TX_EXPIRY_DELTA + 1;
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
|
self.mempool.retain(|t, b| {
|
||||||
|
let mined = ids.contains(t) || rel.contains_key(t);
|
||||||
|
let keep = !mined && h < *b + MEMPOOL_KEEP;
|
||||||
|
if !keep && !mined {
|
||||||
|
expired.insert(*t);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
});
|
||||||
|
if !expired.is_empty() {
|
||||||
|
for n in self.notes.iter_mut() {
|
||||||
|
if n.pending.map_or(false, |p| expired.contains(&p)) {
|
||||||
|
n.pending = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
||||||
.cloned().collect();
|
.cloned().collect();
|
||||||
|
|||||||
Reference in New Issue
Block a user