Compare commits
44
Commits
a30a083df0
...
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 |
@@ -553,7 +553,6 @@ set(APP_SOURCES
|
||||
src/ui/windows/console_selection_controller.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/console_text_layout.cpp
|
||||
src/ui/windows/settings_window.cpp
|
||||
src/ui/pages/settings_page.cpp
|
||||
src/ui/windows/about_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_tab.h
|
||||
src/ui/windows/console_tab_helpers.h
|
||||
src/ui/windows/settings_window.h
|
||||
src/ui/windows/about_dialog.h
|
||||
src/ui/windows/faq_dialog.h
|
||||
src/ui/windows/faq_content.h
|
||||
|
||||
@@ -1226,6 +1226,7 @@ pill-rounding = { size = 12.0 }
|
||||
padding-x = { size = 12.0 }
|
||||
glass-fill-alpha = { size = 18.0 }
|
||||
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
|
||||
badge-fill-alpha = { size = 0.35 } # "+N more" badge fill opacity (fraction of the accent alpha)
|
||||
glass-border-alpha = { size = 35.0 }
|
||||
progress-bar-height = { size = 2.0 }
|
||||
duration-info = { size = 1.0 }
|
||||
|
||||
@@ -48,6 +48,36 @@ for f in "${files[@]}"; do
|
||||
fi
|
||||
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
|
||||
echo "" >&2
|
||||
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
||||
|
||||
+191
-29
@@ -36,11 +36,11 @@
|
||||
#include "ui/windows/explorer_tab.h"
|
||||
#include "ui/windows/seed_display.h"
|
||||
#include "ui/windows/market_tab.h"
|
||||
#include "ui/windows/settings_window.h"
|
||||
#include "ui/windows/about_dialog.h"
|
||||
#include "ui/windows/faq_dialog.h"
|
||||
#include "embedded/IconsMaterialDesign.h"
|
||||
#include "ui/windows/key_export_dialog.h"
|
||||
#include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers
|
||||
#include "ui/windows/transaction_details_dialog.h"
|
||||
#include "ui/windows/qr_popup_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(import_key_input_, sizeof(import_key_input_)); // pasted private key (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
|
||||
@@ -943,6 +965,9 @@ void App::update()
|
||||
lite_wallet_->pumpAsyncOpen();
|
||||
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
||||
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.
|
||||
lite_wallet_->pumpWalletOps();
|
||||
// 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:
|
||||
default:
|
||||
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;
|
||||
}
|
||||
break;
|
||||
@@ -1963,6 +1992,12 @@ void App::render()
|
||||
}
|
||||
settings_->setWizardCompleted(true);
|
||||
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)
|
||||
@@ -2387,6 +2422,8 @@ void App::render()
|
||||
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||
else
|
||||
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_);
|
||||
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
||||
@@ -2528,9 +2565,6 @@ void App::render()
|
||||
// content area, status bar, and every other widget in this window.
|
||||
// ====================================================================
|
||||
PERF_BEGIN(_perfDialogs);
|
||||
if (show_settings_) {
|
||||
ui::RenderSettingsWindow(this, &show_settings_);
|
||||
}
|
||||
|
||||
if (show_about_) {
|
||||
ui::RenderAboutDialog(this, &show_about_);
|
||||
@@ -2548,7 +2582,11 @@ void App::render()
|
||||
if (show_import_key_) {
|
||||
renderImportKeyDialog();
|
||||
}
|
||||
|
||||
|
||||
if (show_import_wallet_file_) {
|
||||
renderImportWalletFileDialog();
|
||||
}
|
||||
|
||||
if (show_export_key_) {
|
||||
renderExportKeyDialog();
|
||||
}
|
||||
@@ -2682,6 +2720,10 @@ void App::renderNodeStatusBanner()
|
||||
in.node_busy = busy.busy;
|
||||
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);
|
||||
if (!banner.show) return;
|
||||
@@ -2720,6 +2762,8 @@ void App::renderNodeStatusBanner()
|
||||
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:
|
||||
default:
|
||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||
@@ -2729,6 +2773,7 @@ void App::renderNodeStatusBanner()
|
||||
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::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
|
||||
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
||||
@@ -2736,6 +2781,7 @@ void App::renderNodeStatusBanner()
|
||||
std::string detailText;
|
||||
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_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();
|
||||
@@ -2825,6 +2871,7 @@ void App::renderNodeStatusBanner()
|
||||
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
||||
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
||||
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(),
|
||||
@@ -3079,6 +3126,10 @@ void App::renderStatusBar()
|
||||
} else if (rpc_busy_gate_->busy()) {
|
||||
// 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"));
|
||||
} 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) {
|
||||
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
||||
int blocksLeft = chainTip - state_.sync.blocks;
|
||||
@@ -3315,7 +3366,7 @@ void App::renderStatusBar()
|
||||
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
|
||||
ImGui::OpenPopup("##AlertHistoryPopup");
|
||||
}
|
||||
@@ -3462,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),
|
||||
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 = [&]() {
|
||||
wallet::secureWipeLiteSecret(seed);
|
||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||
restoreBirthday = 0;
|
||||
restoring = false;
|
||||
restoreErr.clear();
|
||||
words.clear();
|
||||
chips.clear();
|
||||
wipeWords(words);
|
||||
wipeChips(chips);
|
||||
progress = 0;
|
||||
step = 0;
|
||||
skipConfirm = false;
|
||||
@@ -3577,7 +3634,7 @@ void App::renderLiteFirstRunPrompt()
|
||||
ImGui::Spacing(); ImGui::Spacing();
|
||||
|
||||
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);
|
||||
std::mt19937 rng{std::random_device{}()};
|
||||
std::shuffle(chips.begin(), chips.end(), rng);
|
||||
@@ -3588,7 +3645,7 @@ void App::renderLiteFirstRunPrompt()
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
||||
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) {
|
||||
skipConfirm = true; // require a second, deliberate click
|
||||
} else {
|
||||
@@ -3651,9 +3708,9 @@ void App::renderLiteFirstRunPrompt()
|
||||
}
|
||||
if (!verified) ImGui::EndDisabled();
|
||||
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();
|
||||
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) {
|
||||
skipConfirm = true; // require a second, deliberate click
|
||||
} else {
|
||||
@@ -3750,7 +3807,7 @@ void App::renderLiteFirstRunPrompt()
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
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));
|
||||
restoreErr.clear();
|
||||
step = 0;
|
||||
@@ -3788,12 +3845,13 @@ void App::renderLiteUnlockPrompt()
|
||||
const float btnW = 130.0f * dp;
|
||||
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
||||
if (doUnlock) {
|
||||
const bool ok = lite_wallet_->unlockWallet(pass);
|
||||
std::string unlockErr;
|
||||
const bool ok = unlockLiteWallet(std::string(pass), &unlockErr);
|
||||
sodium_memzero(pass, sizeof(pass));
|
||||
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
||||
else {
|
||||
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
|
||||
ui::Notifications::instance().error(TR("lite_unlock_failed"));
|
||||
wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
|
||||
ui::Notifications::instance().error(unlockErr);
|
||||
}
|
||||
lite_unlock_prompt_ = false;
|
||||
ImGui::CloseCurrentPopup();
|
||||
@@ -3895,6 +3953,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
|
||||
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()
|
||||
{
|
||||
namespace m = ui::material;
|
||||
@@ -4322,8 +4459,11 @@ void App::renderExportKeyDialog()
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
||||
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
||||
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
||||
if (m::TactileButton(TR("copy")))
|
||||
ImGui::SetClipboardText(export_result_);
|
||||
if (m::TactileButton(TR("copy"))) {
|
||||
// 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_) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
@@ -4707,7 +4847,7 @@ void App::renderSeedBackupDialog()
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
||||
ImGui::Spacing();
|
||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
|
||||
{ auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||
ImGui::Spacing();
|
||||
if (!seed_backup_status_.empty()) {
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
@@ -4877,7 +5017,7 @@ void App::renderSeedMigrationDialog()
|
||||
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
||||
{
|
||||
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::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
||||
@@ -4999,7 +5139,7 @@ void App::renderSeedMigrationDialog()
|
||||
ImGui::Spacing();
|
||||
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
||||
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();
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||
@@ -5024,6 +5164,22 @@ void App::renderSeedMigrationDialog()
|
||||
{
|
||||
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 (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
||||
@@ -5063,7 +5219,8 @@ void App::renderSeedMigrationDialog()
|
||||
|
||||
case SeedMigrationStep::Confirming: {
|
||||
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 &&
|
||||
seed_migration_legacy_remaining_ <= dust;
|
||||
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
||||
@@ -5083,8 +5240,8 @@ void App::renderSeedMigrationDialog()
|
||||
ImGui::Spacing();
|
||||
if (confirmed && legacyEmpty) {
|
||||
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
||||
beginAdoptSeedWallet();
|
||||
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
||||
verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
|
||||
} 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
|
||||
// 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.
|
||||
@@ -5108,6 +5265,10 @@ void App::renderSeedMigrationDialog()
|
||||
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 {
|
||||
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
||||
}
|
||||
@@ -6036,16 +6197,16 @@ bool App::startEmbeddedDaemon()
|
||||
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||
switch (state) {
|
||||
case daemon::EmbeddedDaemon::State::Starting:
|
||||
setDaemonStatus(TR("sb_starting_daemon"));
|
||||
setDaemonStatus(util::trCopy("sb_starting_daemon"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Running:
|
||||
setDaemonStatus(TR("sb_dragonxd_running"));
|
||||
setDaemonStatus(util::trCopy("sb_dragonxd_running"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Stopping:
|
||||
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
||||
setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Stopped:
|
||||
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
||||
setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
|
||||
break;
|
||||
case daemon::EmbeddedDaemon::State::Error:
|
||||
setDaemonStatus("Error: " + msg);
|
||||
@@ -8182,6 +8343,7 @@ void App::restartDaemon()
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
||||
if (!decision.allowed) return;
|
||||
if (refuseIfNodeBusy()) return;
|
||||
daemon_restart_pending_.store(false); // a restart is actually happening — clear the reminder
|
||||
daemon_restarting_ = true;
|
||||
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
|
||||
@@ -114,6 +114,7 @@ class App {
|
||||
public:
|
||||
App();
|
||||
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();
|
||||
|
||||
// Non-copyable
|
||||
@@ -332,6 +333,10 @@ public:
|
||||
// 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).
|
||||
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
|
||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||
void rebuildLiteWallet(bool force = false);
|
||||
@@ -448,6 +453,9 @@ public:
|
||||
// Address operations
|
||||
void createNewZAddress(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)
|
||||
void hideAddress(const std::string& addr);
|
||||
@@ -557,10 +565,11 @@ public:
|
||||
private:
|
||||
App& app_;
|
||||
};
|
||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
|
||||
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
|
||||
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
|
||||
// 30 s, as before), so a user's own send is never cut off mid-call.
|
||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
|
||||
// maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
|
||||
// refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
|
||||
// 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();
|
||||
// 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.
|
||||
@@ -641,6 +650,9 @@ public:
|
||||
// Dialog triggers (used by settings page to open modal dialogs)
|
||||
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
||||
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 showBackupDialog() { show_backup_ = true; }
|
||||
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
||||
@@ -769,6 +781,9 @@ public:
|
||||
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
||||
*/
|
||||
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
|
||||
// (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.
|
||||
@@ -1184,6 +1199,7 @@ private:
|
||||
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 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);
|
||||
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
|
||||
@@ -1311,6 +1327,7 @@ private:
|
||||
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
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's daemon won't start/stay up; the main loop then reverts active_wallet_file to
|
||||
@@ -1339,6 +1356,7 @@ private:
|
||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||
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_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||
@@ -1401,10 +1419,15 @@ private:
|
||||
// UI State
|
||||
bool quit_requested_ = false;
|
||||
bool show_demo_window_ = false;
|
||||
bool show_settings_ = false;
|
||||
bool show_about_ = false;
|
||||
bool show_faq_ = 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_backup_ = false;
|
||||
bool show_seed_backup_ = false;
|
||||
@@ -2046,6 +2069,7 @@ private:
|
||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||
void renderImportKeyDialog();
|
||||
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
|
||||
void renderExportKeyDialog();
|
||||
void renderBackupDialog();
|
||||
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
||||
|
||||
+349
-51
@@ -41,6 +41,10 @@
|
||||
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||
#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 "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||
@@ -559,15 +563,31 @@ void App::tryConnect()
|
||||
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);
|
||||
std::string connectErr = rpc_->getLastConnectError();
|
||||
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
||||
bool warmingUp = rpc_->isWarmingUp();
|
||||
std::string warmupStatus = rpc_->getWarmupStatus();
|
||||
|
||||
|
||||
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
|
||||
if (connected) connect_backoff_.reset();
|
||||
if (connected) {
|
||||
@@ -596,12 +616,19 @@ void App::tryConnect()
|
||||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
||||
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,
|
||||
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
||||
cookieConfig.use_tls);
|
||||
return [this, cookieConfig, ok, attempt]() {
|
||||
return [this, cookieConfig, ok, attempt, connectSession]() {
|
||||
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) {
|
||||
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
||||
saved_config_ = cookieConfig;
|
||||
@@ -832,6 +859,7 @@ void App::onConnected()
|
||||
fast_worker_->start();
|
||||
}
|
||||
// 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 {
|
||||
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
||||
saved_config_.rpcuser, saved_config_.rpcpassword,
|
||||
@@ -891,8 +919,10 @@ void App::onDisconnected(const std::string& reason)
|
||||
pending_opids_.clear();
|
||||
pending_send_info_.clear();
|
||||
// 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_) {
|
||||
if (entry.second) entry.second(false, reason);
|
||||
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
|
||||
}
|
||||
pending_send_callbacks_.clear();
|
||||
consecutive_core_failures_ = 0;
|
||||
@@ -950,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
|
||||
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
||||
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_) {
|
||||
fast_rpc_->disconnect();
|
||||
@@ -1111,7 +1148,10 @@ void App::abortMaintenanceCalls()
|
||||
{
|
||||
// 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
|
||||
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
|
||||
@@ -3772,7 +3812,7 @@ void App::banPeer(const std::string& ip, int duration_seconds)
|
||||
}
|
||||
return [this, 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(); }
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3790,7 +3830,7 @@ void App::unbanPeer(const std::string& ip)
|
||||
}
|
||||
return [this, 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(); }
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3809,6 +3849,7 @@ void App::clearBans()
|
||||
return [this, err]() {
|
||||
if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
|
||||
state_.banned_peers.clear();
|
||||
ui::Notifications::instance().success(TR("toast_bans_cleared"));
|
||||
refreshPeerInfo();
|
||||
};
|
||||
});
|
||||
@@ -3916,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
|
||||
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
||||
: "Receive tab / New transparent address";
|
||||
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
||||
std::string addr;
|
||||
std::string addr, genErr;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||
json result = rpc_->call(rpcMethod);
|
||||
addr = result.get<std::string>();
|
||||
} catch (const std::exception& e) {
|
||||
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, shielded, typeStr]() {
|
||||
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();
|
||||
}
|
||||
if (!addr.empty()) {
|
||||
// Inject immediately so UI can select the address next frame
|
||||
AddressInfo info;
|
||||
@@ -5398,6 +5456,7 @@ bool App::lockLiteWallet()
|
||||
if (!lite_wallet_) return false;
|
||||
const bool ok = lite_wallet_->lockWallet();
|
||||
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`
|
||||
// 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
|
||||
@@ -5412,6 +5471,69 @@ bool App::lockLiteWallet()
|
||||
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()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
||||
@@ -5624,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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -5634,7 +5798,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
const std::string& destExisting)
|
||||
{
|
||||
if (!state_.connected || !rpc_ || !worker_) {
|
||||
sweep_status_ = "Not connected to the daemon.";
|
||||
sweep_status_ = TR("err_not_connected_daemon");
|
||||
sweep_step_ = SweepStep::Error;
|
||||
return;
|
||||
}
|
||||
@@ -5646,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
|
||||
// can't sign, and this must never accidentally route to z_importviewingkey.
|
||||
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;
|
||||
return;
|
||||
}
|
||||
@@ -5664,7 +5828,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
}
|
||||
|
||||
sweep_step_ = SweepStep::Running;
|
||||
sweep_status_ = "Importing key & rescanning…";
|
||||
sweep_status_ = TR("sweep_status_importing");
|
||||
sweep_txid_.clear();
|
||||
sweep_dest_shown_.clear();
|
||||
|
||||
@@ -5725,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.");
|
||||
|
||||
// 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 {
|
||||
try {
|
||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||
return b.is_number() ? b.get<double>()
|
||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||
} catch (...) { return 0.0; }
|
||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||
return b.is_number() ? b.get<double>()
|
||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||
};
|
||||
double bal = balAt(1);
|
||||
if (bal <= 0.0) {
|
||||
@@ -5758,7 +5922,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
} catch (const std::exception& e) {
|
||||
err = e.what();
|
||||
} 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).
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||
@@ -5766,16 +5930,16 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
||||
invalidateAddressValidationCache();
|
||||
refreshAddresses();
|
||||
if (!err.empty()) {
|
||||
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
|
||||
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
|
||||
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
|
||||
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
|
||||
if (err == "__NOFUNDS__") sweep_status_ = TR("sweep_err_nofunds");
|
||||
else if (err == "__UNCONFIRMED__") sweep_status_ = TR("sweep_err_unconfirmed");
|
||||
else if (err == "__ALREADY__") sweep_status_ = TR("sweep_err_already");
|
||||
else if (err == "__DUST__") sweep_status_ = TR("sweep_err_dust");
|
||||
else sweep_status_ = err;
|
||||
sweep_step_ = SweepStep::Error;
|
||||
return;
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
@@ -6282,7 +6446,7 @@ void App::showSeedMigrationDialog()
|
||||
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
||||
trackOperation(sweepOpid);
|
||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||
seed_migration_status_ = "Checking on the previous sweep…";
|
||||
seed_migration_status_ = TR("migrate_status_checking_sweep");
|
||||
break;
|
||||
case MigrationResume::SweepGate:
|
||||
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
||||
@@ -6360,15 +6524,15 @@ void App::beginSweepToSeedWallet()
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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::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
|
||||
// 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
|
||||
@@ -6384,7 +6548,27 @@ void App::beginSweepToSeedWallet()
|
||||
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
||||
opid = res.value("opid", std::string());
|
||||
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]() {
|
||||
if (!err.empty() || opid.empty()) {
|
||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||
@@ -6408,7 +6592,7 @@ void App::beginSweepToSeedWallet()
|
||||
}
|
||||
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
||||
trackOperation(opid);
|
||||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
||||
seed_migration_status_ = TR("migrate_status_waiting_accept");
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -6560,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
|
||||
// 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.
|
||||
@@ -6567,7 +6802,7 @@ void App::beginAdoptSeedWallet()
|
||||
{
|
||||
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
||||
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; }
|
||||
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
||||
daemon_restarting_ = true;
|
||||
@@ -6621,21 +6856,53 @@ void App::beginAdoptSeedWallet()
|
||||
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
||||
if (!fs::exists(newWallet)) {
|
||||
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 {
|
||||
bool movedLegacy = false;
|
||||
if (fs::exists(legacy)) {
|
||||
fs::rename(legacy, backup, ec);
|
||||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
||||
else movedLegacy = true;
|
||||
}
|
||||
if (err.empty()) {
|
||||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
||||
if (ec) {
|
||||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
||||
err = "Could not install the new wallet; your original wallet was restored.";
|
||||
} else {
|
||||
swapDone = true;
|
||||
// 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)) {
|
||||
backupCreated = !fs::exists(backup);
|
||||
fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak
|
||||
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()) {
|
||||
fs::rename(staged, legacy, ec);
|
||||
if (ec) err = "Could not install the new wallet; your original wallet was left unchanged.";
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6916,9 +7183,21 @@ void App::rebuildWalletDatabase()
|
||||
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
||||
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
||||
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
||||
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
||||
// 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;
|
||||
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());
|
||||
|
||||
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
||||
@@ -7104,7 +7383,14 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
||||
wallet::LiteSendRequest req;
|
||||
wallet::LiteSendRecipient recipient;
|
||||
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;
|
||||
req.recipients.push_back(std::move(recipient));
|
||||
if (!lite_wallet_->sendTransaction(req)) {
|
||||
@@ -7188,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,
|
||||
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
|
||||
std::string result_str;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||
@@ -7196,12 +7483,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
maybeInFlight = rpc::sendReplyMayBeLost(e);
|
||||
} catch (...) {
|
||||
// Non-std throw must not escape into the worker — the main-thread callback
|
||||
// below would never run, leaving a stuck "Sending…" spinner.
|
||||
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 (ok) {
|
||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||
@@ -7224,7 +7512,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
||||
}
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -7409,7 +7707,7 @@ bool App::runtimeRescan(int startHeight)
|
||||
rescan_confirmed_active_ = true;
|
||||
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
||||
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();
|
||||
last_tx_block_height_ = -1;
|
||||
invalidateShieldedHistoryScanProgress(true);
|
||||
|
||||
+24
-15
@@ -548,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
|
||||
}
|
||||
|
||||
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;
|
||||
lock_unlock_in_progress_ = true;
|
||||
|
||||
@@ -598,6 +607,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
void App::lockWallet() {
|
||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||
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;
|
||||
|
||||
// Use fast-lane worker to avoid blocking behind refreshData.
|
||||
@@ -790,12 +800,11 @@ void App::checkAutoLock() {
|
||||
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
||||
if (!state_.isEncrypted() || state_.isLocked()) return;
|
||||
|
||||
// Don't auto-lock while mining — mining is a long-running intentional
|
||||
// operation and locking the wallet on the daemon side stops solo mining.
|
||||
bool miningActive = state_.mining.generate
|
||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
||||
if (miningActive) {
|
||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
||||
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
|
||||
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
|
||||
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
|
||||
if (state_.mining.generate) {
|
||||
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
|
||||
idle_.noteActivity();
|
||||
return;
|
||||
}
|
||||
@@ -1547,7 +1556,7 @@ void App::renderEncryptWalletDialog() {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -1558,7 +1567,7 @@ void App::renderEncryptWalletDialog() {
|
||||
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
||||
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
||||
: (!encMatch ? TR("enc_mismatch") : "");
|
||||
if (*why) ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", why);
|
||||
if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
|
||||
}
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
@@ -1642,7 +1651,7 @@ void App::renderEncryptWalletDialog() {
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
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();
|
||||
@@ -1760,7 +1769,7 @@ void App::renderEncryptWalletDialog() {
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
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();
|
||||
@@ -1841,7 +1850,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
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();
|
||||
@@ -2089,7 +2098,7 @@ void App::renderDecryptWalletDialog() {
|
||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
// 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)) {
|
||||
// In progress - animate
|
||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||
@@ -2248,7 +2257,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
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();
|
||||
@@ -2378,7 +2387,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
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();
|
||||
@@ -2447,7 +2456,7 @@ void App::renderPinDialogs() {
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
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();
|
||||
|
||||
@@ -570,8 +570,6 @@ void App::buildSweepCatalog()
|
||||
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
|
||||
add("modal-about", ui::NavPage::Overview,
|
||||
[](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.
|
||||
add("modal-seed-backup", ui::NavPage::Overview,
|
||||
|
||||
+2
-1
@@ -1456,7 +1456,8 @@ void App::renderFirstRunWizard() {
|
||||
wizard_phase_ = WizardPhase::Done;
|
||||
settings_->setWizardCompleted(true);
|
||||
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::PopStyleVar();
|
||||
|
||||
@@ -108,8 +108,18 @@ bool ChatDatabase::append(const ChatMessage& message)
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
std::string plain;
|
||||
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 (!encrypted) return false;
|
||||
|
||||
@@ -187,8 +197,16 @@ bool ChatDatabase::upsert(const ChatMessage& message)
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message);
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
std::string plain;
|
||||
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 (!encrypted) return false;
|
||||
|
||||
@@ -416,7 +434,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
|
||||
json["ts"] = message.timestamp;
|
||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "chat_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <sodium.h>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
@@ -112,6 +113,10 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
|
||||
}
|
||||
|
||||
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();
|
||||
seen_.clear();
|
||||
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
||||
|
||||
@@ -271,6 +271,7 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||
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_);
|
||||
@@ -599,6 +600,7 @@ bool Settings::save(const std::string& path)
|
||||
}
|
||||
j["wizard_completed"] = wizard_completed_;
|
||||
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_;
|
||||
|
||||
@@ -392,6 +392,8 @@ public:
|
||||
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||
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.
|
||||
@@ -745,6 +747,7 @@ private:
|
||||
std::map<std::string, AddressMeta> address_meta_;
|
||||
bool wizard_completed_ = 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
|
||||
|
||||
@@ -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
|
||||
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||
// string that would then be all getLastError()/the UI ever sees.
|
||||
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
||||
: last_error_;
|
||||
std::string detail = getLastError();
|
||||
if (detail.empty()) detail = "Failed to start dragonxd process";
|
||||
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||
setState(State::Error, detail);
|
||||
return false;
|
||||
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
char errBuf[256] = {0};
|
||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
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());
|
||||
return false;
|
||||
}
|
||||
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
// Create pipe for stdout/stderr
|
||||
int pipefd[2];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
// macOS, which has no pipe2().
|
||||
int execpipe[2];
|
||||
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[1]);
|
||||
return false;
|
||||
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == -1) {
|
||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
||||
setLastError("Fork failed: " + std::string(strerror(errno)));
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
close(execpipe[0]);
|
||||
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
close(pipefd[0]);
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||
" — not executable or wrong architecture";
|
||||
setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||
" — not executable or wrong architecture");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -504,6 +504,8 @@ private:
|
||||
std::atomic<bool> external_daemon_detected_{false};
|
||||
std::string last_error_;
|
||||
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_
|
||||
std::string process_output_;
|
||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||
@@ -511,14 +513,14 @@ private:
|
||||
|
||||
// Process handle
|
||||
#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)
|
||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||
std::string launch_binary_; // binary path used (for diagnostics)
|
||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||
#else
|
||||
pid_t process_pid_ = 0;
|
||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||
std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
|
||||
std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||
void tailDebugLog();
|
||||
|
||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||
|
||||
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||
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
|
||||
|
||||
+32
-2
@@ -75,6 +75,13 @@ struct TransactionInfo {
|
||||
std::string getTypeDisplay() const;
|
||||
bool isConfirmed() const { return confirmations >= 1; }
|
||||
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
|
||||
@@ -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.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) {
|
||||
if (a.size() != b.size()) return false;
|
||||
for (std::size_t i = 0; i < a.size(); ++i)
|
||||
@@ -189,6 +205,11 @@ struct SyncInfo {
|
||||
bool tip_known = false;
|
||||
|
||||
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); }
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -370,12 +391,21 @@ struct WalletState {
|
||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
||||
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||
std::uint64_t tx_revision = 1;
|
||||
void touchTransactions() { ++tx_revision; }
|
||||
// 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(); }
|
||||
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
||||
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||
if (sameTransactionList(transactions, next)) return false;
|
||||
replaceTransactions(std::move(next));
|
||||
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;
|
||||
}
|
||||
int64_t last_peer_update = 0;
|
||||
|
||||
+9
-18
@@ -460,15 +460,14 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build the content in memory, then write it 0600 from the start (writeFileAtomically with
|
||||
// restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
|
||||
// another local user could read the freshly-generated rpcuser/rpcpassword.
|
||||
std::ostringstream file;
|
||||
|
||||
// Get the data directory for exportdir
|
||||
std::string dataDir = getDefaultDataDir();
|
||||
|
||||
|
||||
file << "# DragonX configuration file\n";
|
||||
file << "# Auto-generated by DragonX Wallet\n";
|
||||
file << "\n";
|
||||
@@ -485,18 +484,10 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
file << "addnode=seed.dragonx.is\n";
|
||||
file << "addnode=node1.dragonx.is\n";
|
||||
file << "addnode=node5.dragonx.is\n";
|
||||
|
||||
file.close();
|
||||
|
||||
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
|
||||
// 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());
|
||||
if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
|
||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
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_WRITEFUNCTION, WriteCallback);
|
||||
// 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_XFERINFOFUNCTION, xferInfoCallback);
|
||||
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);
|
||||
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;
|
||||
|
||||
// Set POST data
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <cctype>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
||||
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;
|
||||
// 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 };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "../util/logger.h"
|
||||
#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 <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)
|
||||
{
|
||||
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 (...) {}
|
||||
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
|
||||
// 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.
|
||||
std::map<std::string, double> total;
|
||||
std::map<std::string, double> spendable;
|
||||
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
|
||||
std::map<std::string, std::int64_t> total;
|
||||
std::map<std::string, std::int64_t> spendable;
|
||||
for (const auto& output : unspent) {
|
||||
auto address = readOptional<std::string>(output, "address");
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (!address || !amount) continue;
|
||||
total[*address] += *amount;
|
||||
if (!address || !amount || !std::isfinite(*amount)) continue;
|
||||
const std::int64_t sats = util::toSats(*amount);
|
||||
total[*address] += sats;
|
||||
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
|
||||
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
||||
for (auto& info : addresses) {
|
||||
auto t = total.find(info.address);
|
||||
auto s = spendable.find(info.address);
|
||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
||||
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 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,
|
||||
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) {
|
||||
bool alreadyTracked = false;
|
||||
for (const auto& existing : transactions) {
|
||||
@@ -1073,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
}
|
||||
}
|
||||
if (!foundExistingMetadata) {
|
||||
info.confirmations = entry.confirmations;
|
||||
info.confirmations = entryConfirmations;
|
||||
info.timestamp = entry.timestamp;
|
||||
}
|
||||
transactions.push_back(std::move(info));
|
||||
@@ -1460,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
auto cached = snapshot.viewTxCache.find(txid);
|
||||
if (cached != snapshot.viewTxCache.end()) {
|
||||
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
|
||||
// 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
|
||||
@@ -1493,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
}
|
||||
|
||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
|
||||
const bool storedRow = rowBacklog.count(txid) > 0;
|
||||
if (storedRow && entry.timestamp == 0) {
|
||||
const auto settled = settledStoredMeta.find(txid);
|
||||
|
||||
@@ -224,6 +224,7 @@ public:
|
||||
std::string from_address;
|
||||
std::int64_t timestamp = 0;
|
||||
int confirmations = 0;
|
||||
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
@@ -427,9 +428,12 @@ public:
|
||||
const std::set<std::string>& miningAddresses = {},
|
||||
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
||||
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,
|
||||
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
|
||||
// (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).
|
||||
|
||||
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
++revision_;
|
||||
// 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.
|
||||
sqlite3_busy_timeout(db_, 250);
|
||||
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
||||
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> results;
|
||||
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty()) return false;
|
||||
++revision_;
|
||||
const bool stored = storeBlockRow(block);
|
||||
if (stored) {
|
||||
spill_.erase(block.height);
|
||||
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
|
||||
|
||||
void ExplorerBlockCache::clearBlocks()
|
||||
{
|
||||
++revision_;
|
||||
spill_.clear();
|
||||
if (!ensureOpen()) return;
|
||||
exec("DELETE FROM explorer_blocks");
|
||||
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
|
||||
void ExplorerBlockCache::pruneTo(int maxRows)
|
||||
{
|
||||
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
|
||||
// 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).
|
||||
@@ -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).
|
||||
if (!spill_.empty() && !currentBestHash.empty() &&
|
||||
(currentHeight < spill_tip_height_ ||
|
||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
|
||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
|
||||
spill_.clear();
|
||||
++revision_;
|
||||
}
|
||||
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
||||
return validation;
|
||||
}
|
||||
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
|
||||
{
|
||||
tip_known_ = false;
|
||||
if (!db_) return;
|
||||
++revision_;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
@@ -44,6 +45,11 @@ public:
|
||||
const std::string& databasePath() const { return database_path_; }
|
||||
|
||||
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)
|
||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||
@@ -102,6 +108,11 @@ private:
|
||||
int open_attempts_ = 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
|
||||
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
||||
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
|
||||
@@ -23,6 +23,7 @@ enum class NodeBannerAction {
|
||||
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)
|
||||
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,
|
||||
@@ -35,6 +36,8 @@ enum class NodeBannerReason {
|
||||
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)
|
||||
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 {
|
||||
@@ -75,6 +78,11 @@ struct NodeBannerInputs {
|
||||
|
||||
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 daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||
@@ -134,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||
? NodeBannerAction::RestartNode
|
||||
: NodeBannerAction::None;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -100,6 +100,15 @@ void Notifications::render()
|
||||
float iconGap = 4.0f * dp;
|
||||
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;
|
||||
// "+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
|
||||
float maxPillW = viewport->WorkSize.x * 0.5f;
|
||||
if (pillWidth > maxPillW) pillWidth = maxPillW;
|
||||
@@ -163,7 +172,7 @@ void Notifications::render()
|
||||
bool clipped = false;
|
||||
if (textFont) {
|
||||
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;
|
||||
if (availTextW > 0.0f &&
|
||||
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
|
||||
@@ -183,8 +192,23 @@ void Notifications::render()
|
||||
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);
|
||||
if (toast_hovered_) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
|
||||
}
|
||||
if (toast_hovered_ && clipped)
|
||||
ImGui::SetTooltip("%s", notif.message.c_str());
|
||||
}
|
||||
|
||||
@@ -169,8 +169,20 @@ public:
|
||||
|
||||
void 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) {
|
||||
max_notifications_ = max;
|
||||
}
|
||||
@@ -191,6 +203,7 @@ private:
|
||||
Notifications& operator=(const Notifications&) = delete;
|
||||
|
||||
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;
|
||||
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
|
||||
std::chrono::steady_clock::time_point last_drawn_{};
|
||||
|
||||
@@ -869,7 +869,7 @@ void RenderSettingsPage(App* app) {
|
||||
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::PushStyleColor(ImGuiCol_Text, material::ErrorVec4());
|
||||
ImGui::BeginDisabled(true);
|
||||
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
||||
ImGui::Selectable(lbl.c_str(), false);
|
||||
@@ -904,7 +904,7 @@ void RenderSettingsPage(App* app) {
|
||||
float cellGap = Layout::spacingLg();
|
||||
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
|
||||
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;
|
||||
|
||||
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::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
|
||||
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())
|
||||
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
|
||||
} else {
|
||||
@@ -1490,6 +1490,9 @@ void RenderSettingsPage(App* app) {
|
||||
last = cy + bh; cy += bh + bgp;
|
||||
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"));
|
||||
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;
|
||||
if (fullNode) {
|
||||
cy += bh + bgp;
|
||||
@@ -1649,20 +1652,20 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
if (isLocked) {
|
||||
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::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 {
|
||||
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||
app->lockWallet();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
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::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
|
||||
// when the column is too narrow for three buttons + status.
|
||||
@@ -1708,7 +1711,11 @@ void RenderSettingsPage(App* app) {
|
||||
|
||||
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
||||
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();
|
||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
||||
|
||||
@@ -1756,10 +1763,10 @@ void RenderSettingsPage(App* app) {
|
||||
}
|
||||
} else {
|
||||
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::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 {
|
||||
@@ -2172,9 +2179,10 @@ void RenderSettingsPage(App* app) {
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||
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));
|
||||
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"));
|
||||
} else {
|
||||
@@ -2331,8 +2339,10 @@ void RenderSettingsPage(App* app) {
|
||||
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
||||
cs.tooltip = TR("copy");
|
||||
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());
|
||||
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
}
|
||||
|
||||
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
||||
@@ -2595,6 +2605,21 @@ void RenderSettingsPage(App* app) {
|
||||
if (ui::DaemonUpdateDialog::consumeInstalled())
|
||||
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 ---
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
|
||||
@@ -2972,7 +2997,7 @@ void RenderSettingsPage(App* app) {
|
||||
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
||||
saveSettingsPageState(app->settings());
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -3001,14 +3026,23 @@ void RenderSettingsPage(App* app) {
|
||||
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);
|
||||
|
||||
// Draw overline label + arrow on top of the invisible button
|
||||
{
|
||||
ImFont* ovFont = Type().overline();
|
||||
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
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
if (!iconFont) iconFont = ovFont;
|
||||
@@ -3016,6 +3050,14 @@ void RenderSettingsPage(App* app) {
|
||||
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) {
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
@@ -3059,9 +3101,33 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
#endif
|
||||
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_debug_select"));
|
||||
ImGui::TextColored(ImVec4(1,1,1,0.35f), "%s", TR("settings_debug_restart_note"));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
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()));
|
||||
// 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
|
||||
static const char* debugCats[] = {
|
||||
@@ -3119,27 +3185,7 @@ void RenderSettingsPage(App* app) {
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// "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"));
|
||||
}
|
||||
// (The "restart daemon" prompt was moved up to directly under the restart note above.)
|
||||
}
|
||||
|
||||
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
|
||||
@@ -3287,12 +3333,12 @@ void RenderSettingsPage(App* app) {
|
||||
// Confirmation dialog for deleting blockchain data
|
||||
if (s_settingsState.confirm_delete_blockchain) {
|
||||
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::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
||||
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::Separator();
|
||||
ImGui::Spacing();
|
||||
@@ -3334,7 +3380,7 @@ void RenderSettingsPage(App* app) {
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -3353,14 +3399,14 @@ void RenderSettingsPage(App* app) {
|
||||
} else {
|
||||
if (s_settingsState.rescan_probe_failed) {
|
||||
// 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::PopStyleColor();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
||||
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();
|
||||
@@ -3389,12 +3435,12 @@ void RenderSettingsPage(App* app) {
|
||||
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
||||
if (s_settingsState.confirm_repair_wallet) {
|
||||
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::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
||||
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::Separator();
|
||||
ImGui::Spacing();
|
||||
@@ -3417,12 +3463,12 @@ void RenderSettingsPage(App* app) {
|
||||
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
||||
if (s_settingsState.confirm_reinstall_daemon) {
|
||||
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::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
||||
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::Separator();
|
||||
ImGui::Spacing();
|
||||
@@ -3446,7 +3492,7 @@ void RenderSettingsPage(App* app) {
|
||||
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
||||
if (s_settingsState.confirm_restart_daemon) {
|
||||
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::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
||||
@@ -3473,12 +3519,12 @@ void RenderSettingsPage(App* app) {
|
||||
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
||||
if (s_settingsState.confirm_lite_redownload) {
|
||||
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::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
||||
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::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
@@ -77,7 +77,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
||||
#ifdef DRAGONX_DEBUG
|
||||
ImGui::Text("%s", TR("about_build_type"));
|
||||
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
|
||||
ImGui::Text("%s", TR("about_build_type"));
|
||||
ImGui::SameLine(valX);
|
||||
@@ -92,7 +92,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
||||
|
||||
ImGui::Text("%s", TR("about_daemon"));
|
||||
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();
|
||||
ImGui::Text("%s", TR("about_chain"));
|
||||
|
||||
+111
-16
@@ -106,6 +106,8 @@ std::string s_delete_name; // its peer name (for the confirm copy / bl
|
||||
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) {
|
||||
@@ -589,6 +591,17 @@ void centeredHint(const char* text) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const ImVec2 origin = ImGui::GetCursorPos();
|
||||
@@ -954,15 +967,7 @@ void RenderChatTab(App* app)
|
||||
{
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
|
||||
if (ImGui::SmallButton(TR("chat_copy_my_address"))) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
|
||||
ImGui::PopStyleColor();
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
|
||||
}
|
||||
@@ -1324,13 +1329,22 @@ void RenderChatTab(App* app)
|
||||
// same row. Hidden while renaming (the field takes the whole span).
|
||||
if (hasAddr && !renaming) {
|
||||
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) {
|
||||
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),
|
||||
material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK);
|
||||
if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz)))
|
||||
material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str());
|
||||
lockHov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
|
||||
ICON_MD_LOCK);
|
||||
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;
|
||||
}
|
||||
// Click-to-copy compact address.
|
||||
@@ -1724,7 +1738,10 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
}
|
||||
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();
|
||||
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
||||
s_msgmenu_index = -1; // popup closed
|
||||
@@ -1864,9 +1881,35 @@ void RenderChatTab(App* app)
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
|
||||
// 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 {
|
||||
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
|
||||
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
||||
centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
@@ -2207,6 +2250,53 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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) ----
|
||||
if (s_show_delete_confirm) {
|
||||
const float dp = Layout::dpiScale();
|
||||
@@ -2394,11 +2484,16 @@ void ResetChatTab()
|
||||
s_show_blocked = false;
|
||||
// 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).
|
||||
// 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_convsHidden = 0;
|
||||
s_convsKey = ~0ull;
|
||||
s_threadCid.clear();
|
||||
s_threadRev = ~0ull;
|
||||
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
|
||||
s_threadMsgs.clear();
|
||||
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.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.
|
||||
|
||||
#include "console_tab.h"
|
||||
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
|
||||
#include "console_command_executor.h"
|
||||
#include "console_command_reference.h"
|
||||
#include "console_input_model.h"
|
||||
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
||||
{
|
||||
std::string result;
|
||||
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
|
||||
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
||||
if (ImGui::IsItemHovered()) {
|
||||
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();
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
} // namespace
|
||||
@@ -1559,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||
{
|
||||
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
|
||||
// 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);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
return true;
|
||||
@@ -1882,6 +1925,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
|
||||
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';
|
||||
run_confirm_cmd_ = nullptr;
|
||||
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);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
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
|
||||
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||
model_.clear();
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <functional>
|
||||
#include <chrono>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -66,6 +68,13 @@ public:
|
||||
*/
|
||||
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).
|
||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||
void requestInputFocus() { focus_input_pending_ = true; }
|
||||
@@ -162,6 +171,10 @@ private:
|
||||
char input_buffer_[4096] = {0};
|
||||
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
|
||||
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)
|
||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ const char* const kSecretConsoleCommands[] = {
|
||||
"importprivkey", "importwallet", "importmulti",
|
||||
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||
"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) {
|
||||
@@ -67,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
||||
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)
|
||||
{
|
||||
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.
|
||||
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
|
||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||
std::string RedactConsoleCommand(const std::string& cmd);
|
||||
|
||||
@@ -445,11 +445,20 @@ private:
|
||||
|
||||
static void renderDone(float, const Progress& p) {
|
||||
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"));
|
||||
ImGui::Spacing();
|
||||
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
|
||||
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"));
|
||||
ImGui::Spacing();
|
||||
if (s_app->isUsingEmbeddedDaemon()) {
|
||||
@@ -459,6 +468,7 @@ private:
|
||||
}
|
||||
ImGui::Spacing();
|
||||
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_later_hint"));
|
||||
} else {
|
||||
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});
|
||||
std::string hash = hashResult.get<std::string>();
|
||||
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]() {
|
||||
applyBlockDetailResult(result, error);
|
||||
};
|
||||
@@ -383,19 +383,27 @@ static void navigateToHash(App* app, const std::string& hash) {
|
||||
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
|
||||
json blockResult, txResult;
|
||||
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 {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
blockResult = rpc->call("getblock", {hash});
|
||||
gotBlock = true;
|
||||
} catch (...) {}
|
||||
} catch (const std::exception& e) { noteErr(e); }
|
||||
if (!gotBlock) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||
txResult = rpc->call("getrawtransaction", {hash, 1});
|
||||
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;
|
||||
if (gotBlock) {
|
||||
int h = blockResult.value("height", -1);
|
||||
@@ -407,6 +415,8 @@ static void navigateToHash(App* app, const std::string& hash) {
|
||||
s_tx_loading = false;
|
||||
s_expanded_tx_idx = 0;
|
||||
s_show_detail_modal = false;
|
||||
} else if (rpcFailed) {
|
||||
s_search_error = std::string(TR("explorer_lookup_failed")) + ": " + util::friendlyRpcError(rpcErr);
|
||||
} else {
|
||||
s_search_error = TR("explorer_hash_not_found");
|
||||
}
|
||||
@@ -836,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
|
||||
std::map<int, BlockSummary> cachedBlocks;
|
||||
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
|
||||
@@ -880,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
}
|
||||
std::map<int, BlockSummary> nextCachedBlocks;
|
||||
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) {
|
||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/export_filename.h"
|
||||
#include "../notifications.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -117,7 +118,7 @@ void ExportAllKeysDialog::render(App* app)
|
||||
} else if (!s_status.empty()) {
|
||||
ImGui::Spacing();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -160,8 +161,11 @@ void ExportAllKeysDialog::render(App* app)
|
||||
if (s_include_t) {
|
||||
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
|
||||
if (app->worker()) {
|
||||
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/export_filename.h"
|
||||
#include "../../data/transaction_csv.h"
|
||||
#include "transactions_tab.h"
|
||||
#include "../notifications.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -18,6 +20,8 @@
|
||||
|
||||
#include <string>
|
||||
#include <ctime>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -27,12 +31,34 @@ static bool s_open = false;
|
||||
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_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()
|
||||
{
|
||||
s_open = true;
|
||||
s_saved_path.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
|
||||
std::time_t now = std::time(nullptr);
|
||||
@@ -74,8 +100,64 @@ void ExportTransactionsDialog::render(App* app)
|
||||
if (!m::BeginOverlayDialog(ov)) return;
|
||||
|
||||
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();
|
||||
|
||||
// Filename + save-location hint.
|
||||
@@ -94,6 +176,8 @@ void ExportTransactionsDialog::render(App* app)
|
||||
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
||||
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
||||
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()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
@@ -109,14 +193,29 @@ void ExportTransactionsDialog::render(App* app)
|
||||
if (doExport) {
|
||||
s_saved_path.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"));
|
||||
} else {
|
||||
std::string configDir = util::Platform::getConfigDir();
|
||||
std::string filepath = configDir + "/" + s_filename;
|
||||
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
|
||||
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
|
||||
const std::string csv = data::formatTransactionsCsv(state.transactions);
|
||||
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
||||
// the config dir by a pasted path).
|
||||
std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
|
||||
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)) {
|
||||
s_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);
|
||||
|
||||
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()) {
|
||||
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.
|
||||
ImGui::Spacing();
|
||||
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) {
|
||||
// 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.)
|
||||
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()) {
|
||||
// Show button to fetch key
|
||||
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_show_key = wantViewing; // viewing keys are less sensitive
|
||||
} 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);
|
||||
s_fetching = false;
|
||||
|
||||
@@ -33,6 +33,7 @@ using namespace material;
|
||||
// Track selected peer for ban action
|
||||
static int s_selected_peer_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
|
||||
static std::string ExtractIP(const std::string& addr)
|
||||
@@ -775,9 +776,11 @@ void RenderPeersTab(App* app)
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||
ImGui::SetClipboardText(peer.addr.c_str());
|
||||
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
||||
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
||||
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
||||
@@ -891,6 +894,7 @@ void RenderPeersTab(App* app)
|
||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||
ImGui::SetClipboardText(banned.address.c_str());
|
||||
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
if (ImGui::MenuItem(TR("peers_unban"))) {
|
||||
app->unbanPeer(banned.address);
|
||||
@@ -931,10 +935,25 @@ void RenderPeersTab(App* app)
|
||||
// ================================================================
|
||||
if (s_show_banned && !state.bannedPeers.empty()) {
|
||||
ImGui::BeginDisabled(!app->isConnected());
|
||||
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button"))) {
|
||||
app->clearBans();
|
||||
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();
|
||||
s_confirm_clear_bans = false;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileSmallButton(TR("cancel"), S.resolveFont("button")))
|
||||
s_confirm_clear_bans = false;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
} else {
|
||||
s_confirm_clear_bans = false; // no stale confirm across tab toggles / after bans clear
|
||||
}
|
||||
|
||||
ImGui::EndChild(); // ##PeersScroll
|
||||
|
||||
@@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
|
||||
} else {
|
||||
s_generating_address = true;
|
||||
if (s_addr_type_filter != 2) {
|
||||
app->createNewZAddress([](const std::string& addr) {
|
||||
app->createNewZAddress([app](const std::string& addr) {
|
||||
s_generating_address = false;
|
||||
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 {
|
||||
s_pending_select_address = addr;
|
||||
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();
|
||||
} else {
|
||||
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;
|
||||
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 {
|
||||
s_pending_select_address = addr;
|
||||
Notifications::instance().success(TR("new_shielded_created"));
|
||||
|
||||
@@ -119,10 +119,11 @@ void RequestPaymentDialog::render(App* app)
|
||||
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
||||
if (app->isConnected()) {
|
||||
s_creating_address = true;
|
||||
app->createNewZAddress([](const std::string& addr) {
|
||||
app->createNewZAddress([app](const std::string& addr) {
|
||||
s_creating_address = false;
|
||||
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 {
|
||||
Notifications::instance().success(TR("new_shielded_created"));
|
||||
// Pre-select the new address if the dialog is still open and none chosen.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <sodium.h>
|
||||
|
||||
#include "imgui.h"
|
||||
#include "../layout.h" // Layout::dpiScale()
|
||||
@@ -15,19 +16,31 @@
|
||||
namespace dragonx {
|
||||
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.
|
||||
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
||||
{
|
||||
std::vector<std::string> words;
|
||||
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
|
||||
std::string cur;
|
||||
for (char c : phrase) {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
||||
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) {
|
||||
char cell[96];
|
||||
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
||||
ImGui::TextUnformatted(cell);
|
||||
if ((i % 4) != 3 && i + 1 < words.size())
|
||||
ImGui::SameLine(((i % 4) + 1) * colStride);
|
||||
sodium_memzero(cell, sizeof(cell));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
#include "../../util/address_validation.h"
|
||||
#include "../../util/i18n.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 "../layout.h"
|
||||
#include "contact_picker.h"
|
||||
@@ -915,8 +917,9 @@ void RenderSendConfirmPopup(App* app) {
|
||||
// since Review. Don't broadcast a now-invalid transaction.
|
||||
const auto& liveState = app->getWalletState();
|
||||
double liveAvail = GetAvailableBalance(app);
|
||||
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
|
||||
(s_amount + s_fee) > liveAvail) {
|
||||
if (!app->isConnected() || liveState.sync.syncing ||
|
||||
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
|
||||
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
||||
s_tx_status = TR("send_cannot_send_now");
|
||||
s_status_success = false;
|
||||
s_status_timestamp = ImGui::GetTime();
|
||||
@@ -954,6 +957,18 @@ void RenderSendConfirmPopup(App* app) {
|
||||
s_amount = 0.0;
|
||||
s_memo[0] = '\0';
|
||||
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 {
|
||||
// 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.
|
||||
@@ -1034,10 +1049,11 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
}
|
||||
bool can_send = app->isConnected() &&
|
||||
!state.sync.syncing &&
|
||||
state.sync.sendTipReady(app->isLiteBuild()) &&
|
||||
is_valid_address &&
|
||||
s_amount > 0 &&
|
||||
s_from_address[0] != '\0' &&
|
||||
total <= available &&
|
||||
util::canAfford(s_amount, s_fee, available) &&
|
||||
sourceSpendable &&
|
||||
!s_sending;
|
||||
|
||||
@@ -1058,7 +1074,7 @@ static void RenderActionButtons(App* app, float width, float 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) {
|
||||
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()));
|
||||
}
|
||||
@@ -1087,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"));
|
||||
else if (state.sync.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')
|
||||
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
||||
else if (is_mining_addr)
|
||||
@@ -1095,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
||||
else if (s_amount <= 0)
|
||||
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"));
|
||||
else if (!sourceSpendable)
|
||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||
@@ -1530,6 +1548,7 @@ void RenderSendTab(App* app)
|
||||
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
||||
// 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_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)
|
||||
{
|
||||
@@ -1555,6 +1574,7 @@ void RenderSendTab(App* app)
|
||||
if (s_amount < 0) s_amount = 0;
|
||||
if (market.price_usd > 0)
|
||||
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)
|
||||
if (market.price_usd > 0 && s_amount > 0) {
|
||||
@@ -1619,7 +1639,7 @@ void RenderSendTab(App* app)
|
||||
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
||||
|
||||
// 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);
|
||||
Type().textColored(TypeStyle::Caption, Error(), buf);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -58,16 +58,16 @@ void TransactionDetailsDialog::render(App* app)
|
||||
ImVec4 type_color;
|
||||
std::string type_display;
|
||||
if (tx.type == "receive") {
|
||||
type_color = ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
|
||||
type_color = material::SuccessVec4();
|
||||
type_display = TR("tx_received");
|
||||
} 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");
|
||||
} else if (tx.type == "generate" || tx.type == "mined") {
|
||||
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
||||
type_display = TR("tx_mined");
|
||||
} 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");
|
||||
} else {
|
||||
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
||||
@@ -79,11 +79,11 @@ void TransactionDetailsDialog::render(App* app)
|
||||
|
||||
// Confirmations badge
|
||||
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) {
|
||||
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 {
|
||||
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();
|
||||
@@ -94,9 +94,9 @@ void TransactionDetailsDialog::render(App* app)
|
||||
ImGui::Text("%s", TR("amount_label"));
|
||||
ImGui::SameLine(lbl.position);
|
||||
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 {
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "transactions_tab.h"
|
||||
#include "../notifications.h"
|
||||
#include "transaction_details_dialog.h"
|
||||
#include "export_transactions_dialog.h"
|
||||
#include "transactions_tab_helpers.h"
|
||||
@@ -36,6 +37,11 @@ namespace ui {
|
||||
|
||||
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) {
|
||||
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
|
||||
// (a sort-combo change re-sorts without regrouping).
|
||||
static history::TxidGroups s_groups;
|
||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
|
||||
if (s_groups_key != contentKey) {
|
||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
|
||||
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_key = contentKey;
|
||||
s_groups_key = structKey;
|
||||
}
|
||||
|
||||
// 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
|
||||
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
||||
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 bool s_display_cache_valid = false;
|
||||
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.
|
||||
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_cache.clear();
|
||||
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_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
|
||||
@@ -565,6 +591,35 @@ void RenderTransactionsTab(App* app)
|
||||
s_filtered_search = search_str;
|
||||
s_filtered_chat_rev = chatRev;
|
||||
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;
|
||||
filtered_count = static_cast<int>(filtered_indices.size());
|
||||
@@ -800,7 +855,12 @@ void RenderTransactionsTab(App* app)
|
||||
{
|
||||
const char* statusStr;
|
||||
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();
|
||||
} else if (tx.confirmations < 10) {
|
||||
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 (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
||||
ImGui::SetClipboardText(tx.address.c_str());
|
||||
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
if (ImGui::MenuItem(TR("copy_txid"))) {
|
||||
ImGui::SetClipboardText(tx.txid.c_str());
|
||||
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
||||
@@ -916,7 +978,10 @@ void RenderTransactionsTab(App* app)
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
||||
ImGui::TextWrapped("%s", tx.txid.c_str());
|
||||
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()));
|
||||
|
||||
// Memo
|
||||
@@ -938,10 +1003,12 @@ void RenderTransactionsTab(App* app)
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
||||
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
||||
ImGui::SetClipboardText(tx.txid.c_str());
|
||||
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
||||
ImGui::SetClipboardText(tx.address.c_str());
|
||||
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
||||
|
||||
@@ -5,12 +5,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
|
||||
namespace dragonx {
|
||||
class App;
|
||||
|
||||
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
|
||||
* Shows transaction history
|
||||
|
||||
@@ -183,7 +183,7 @@ void ValidateAddressDialog::render(App* app)
|
||||
|
||||
if (s_validating) {
|
||||
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();
|
||||
@@ -194,15 +194,15 @@ void ValidateAddressDialog::render(App* app)
|
||||
ImGui::Spacing();
|
||||
|
||||
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 {
|
||||
// Valid/Invalid indicator
|
||||
ImGui::Text("%s", TR("validate_status"));
|
||||
ImGui::SameLine(lbl.position);
|
||||
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 {
|
||||
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) {
|
||||
@@ -219,7 +219,7 @@ void ValidateAddressDialog::render(App* app)
|
||||
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
||||
: "validate_ownership_unknown_offline"));
|
||||
} else if (s_is_mine) {
|
||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine"));
|
||||
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_is_mine"));
|
||||
} else {
|
||||
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
||||
}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
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 dragonx
|
||||
|
||||
+22
-45
@@ -1125,56 +1125,33 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
|
||||
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
||||
const bool haveSHA256 = !expectedSha.empty();
|
||||
|
||||
std::string expectedMd5;
|
||||
bool haveMD5 = false;
|
||||
if (!haveSHA256) { // MD5 only as a fallback when no SHA-256 is published
|
||||
std::string md5Content = httpGetString(baseUrl + "/" + kZipName + ".md5", "[bootstrap]", opt);
|
||||
if (cancel_requested_) return false;
|
||||
expectedMd5 = parseChecksumFile(md5Content, 32);
|
||||
haveMD5 = !expectedMd5.empty();
|
||||
}
|
||||
|
||||
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. "
|
||||
// SHA-256 is now required: the collision-prone MD5 fallback was removed. (A same-origin checksum
|
||||
// only protects against transport corruption, not a malicious server — true authenticity needs a
|
||||
// detached ed25519 signature like the daemon/xmrig updaters; that is a separate release-coordinated
|
||||
// change, since it requires publishing per-archive .sig files and pinning a key.)
|
||||
if (!haveSHA256) {
|
||||
DEBUG_LOGF("[Bootstrap] No SHA-256 published - refusing to install an unverified archive\n");
|
||||
setProgress(State::Failed, "Verification failed: no SHA-256 checksum was published for the bootstrap. "
|
||||
"Refusing to install an unverified archive.");
|
||||
return false; // fail CLOSED; archive is KEPT
|
||||
}
|
||||
|
||||
if (haveSHA256) {
|
||||
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
||||
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
||||
if (cancel_requested_) return false;
|
||||
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
|
||||
if (expectedSha != actual) {
|
||||
last_verify_mismatch_ = true;
|
||||
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
|
||||
expectedSha.c_str(), actual.c_str());
|
||||
setProgress(State::Failed,
|
||||
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
||||
"Expected: " + expectedSha.substr(0, 16) + "...\n"
|
||||
"Got: " + actual.substr(0, 16) + "...\n"
|
||||
"Try again or use a fresh download.");
|
||||
return false;
|
||||
}
|
||||
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, "Verifying SHA-256...", 0, 1);
|
||||
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
||||
if (cancel_requested_) return false;
|
||||
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
|
||||
if (expectedSha != actual) {
|
||||
last_verify_mismatch_ = true;
|
||||
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
|
||||
expectedSha.c_str(), actual.c_str());
|
||||
setProgress(State::Failed,
|
||||
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
||||
"Expected: " + expectedSha.substr(0, 16) + "...\n"
|
||||
"Got: " + actual.substr(0, 16) + "...\n"
|
||||
"Try again or use a fresh download.");
|
||||
return false;
|
||||
}
|
||||
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
|
||||
|
||||
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
||||
restrictToHttps(curl);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
||||
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_WRITEDATA, fp);
|
||||
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_TIMEOUT, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||
|
||||
@@ -114,6 +114,11 @@ void I18n::overlayTranslations(const nlohmann::json& translations)
|
||||
|
||||
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
|
||||
std::string rel_path = "res/lang/" + locale + ".json";
|
||||
const std::string exe_dir = Platform::getExecutableDirectory();
|
||||
@@ -268,6 +273,10 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["chat_retry"] = "Retry";
|
||||
strings_["chat_jump_latest"] = "Latest";
|
||||
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_copy_my_address"] = "Copy my chat address";
|
||||
strings_["chat_my_address_tip"] = "Share this address so others can message you";
|
||||
@@ -315,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_["chat_copy_address_tip"] = "Click to copy address";
|
||||
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_rename"] = "Rename contact";
|
||||
strings_["chat_rename_hint"] = "Contact name";
|
||||
@@ -474,6 +487,21 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["mig_receive_addr"] = "New wallet receive address:";
|
||||
strings_["mig_copy_seed"] = "Copy seed";
|
||||
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.";
|
||||
@@ -484,6 +512,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["mig_to"] = "To: ";
|
||||
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
||||
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_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.";
|
||||
@@ -498,6 +528,8 @@ void I18n::loadBuiltinEnglish()
|
||||
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_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_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.";
|
||||
@@ -598,6 +630,15 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["settings_merge_to_address"] = "Merge to Address…";
|
||||
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
||||
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_export_key"] = "Export Key…";
|
||||
strings_["settings_export_all"] = "Export All…";
|
||||
@@ -773,6 +814,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them";
|
||||
strings_["wiz_encrypt_continue"] = "Encrypt & Continue";
|
||||
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_pin_optional"] = "Quick-Unlock PIN (optional)";
|
||||
strings_["wiz_pin_label"] = "PIN (4-8 digits):";
|
||||
@@ -1058,10 +1101,14 @@ void I18n::loadBuiltinEnglish()
|
||||
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_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";
|
||||
|
||||
// Settings: additional tooltips (keys/data row)
|
||||
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_export_key"] = "Export the private key for the selected address";
|
||||
strings_["tt_export_all"] = "Export all private keys to a file";
|
||||
@@ -1324,6 +1371,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["lite_unlock_btn"] = "Unlock";
|
||||
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
||||
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
|
||||
strings_["pay_from"] = "Pay From";
|
||||
@@ -1475,6 +1523,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["confirmations"] = "Confirmations";
|
||||
strings_["confirmed"] = "Confirmed";
|
||||
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";
|
||||
@@ -1567,6 +1617,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sb_mining_hs"] = "%.1f H/s";
|
||||
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
||||
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"] = "Rescanning";
|
||||
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
||||
@@ -1600,6 +1651,9 @@ void I18n::loadBuiltinEnglish()
|
||||
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.
|
||||
strings_["data_stale_prefix"] = "Updated";
|
||||
strings_["data_stale_tooltip"] =
|
||||
@@ -1991,6 +2045,9 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
|
||||
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
|
||||
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_clear_bans_failed"] = "Couldn't clear bans: ";
|
||||
strings_["toast_key_export_failed"] = "Key export failed: ";
|
||||
@@ -2000,6 +2057,11 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["console_clear"] = "Clear";
|
||||
strings_["console_clear_console"] = "Clear Console";
|
||||
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_insert"] = "Click to insert";
|
||||
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
||||
@@ -2114,6 +2176,17 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["export_tx_file_fail"] = "Failed to create CSV file";
|
||||
strings_["export_tx_none"] = "No transactions to export";
|
||||
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";
|
||||
|
||||
// --- Import Key Dialog ---
|
||||
@@ -2165,6 +2238,8 @@ void I18n::loadBuiltinEnglish()
|
||||
|
||||
// --- Key Export Dialog ---
|
||||
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_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.";
|
||||
@@ -2482,6 +2557,10 @@ void I18n::loadBuiltinEnglish()
|
||||
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_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_failed"] = "Update failed";
|
||||
strings_["daemon_update_unknown_error"] = "Unknown error.";
|
||||
@@ -2538,6 +2617,8 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["peers_blocks"] = "Blocks";
|
||||
strings_["peers_blocks_left"] = "%d blocks left";
|
||||
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_connected"] = "Connected";
|
||||
strings_["peers_connected_count"] = "Connected: %d";
|
||||
@@ -2639,10 +2720,12 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
||||
strings_["send_tooltip_select_source"] = "Select a source address first";
|
||||
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_total"] = "Total";
|
||||
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_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
||||
strings_["send_tx_success"] = "Transaction sent successfully!";
|
||||
@@ -2828,6 +2911,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
|
||||
strings_["explorer_invalid_response"] = "Invalid response from daemon";
|
||||
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_no_results"] = "No matching cached blocks";
|
||||
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
|
||||
@@ -3055,6 +3139,15 @@ const char* I18n::translate(const char* key) const
|
||||
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)
|
||||
{
|
||||
// Check if already registered
|
||||
|
||||
+22
-1
@@ -4,6 +4,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
@@ -38,7 +40,16 @@ public:
|
||||
* @return Translated string, or key if not found
|
||||
*/
|
||||
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
|
||||
*/
|
||||
@@ -66,6 +77,9 @@ private:
|
||||
void overlayTranslations(const nlohmann::json& translations);
|
||||
|
||||
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::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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
|
||||
@@ -10,6 +10,29 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#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 util {
|
||||
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
||||
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
||||
|
||||
if (key == "amount" || key == "amt") {
|
||||
try {
|
||||
result.amount = std::stod(value);
|
||||
} catch (...) {
|
||||
// Invalid amount, keep as 0
|
||||
double a = 0.0;
|
||||
if (!parseStrictAmount(value, a)) {
|
||||
result.error = "Invalid amount";
|
||||
result.valid = false;
|
||||
return result; // reject the whole URI rather than silently treating it as 0
|
||||
}
|
||||
result.amount = a;
|
||||
} else if (key == "label") {
|
||||
result.label = value;
|
||||
} else if (key == "memo") {
|
||||
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
||||
}
|
||||
|
||||
// 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";
|
||||
return result;
|
||||
}
|
||||
|
||||
+46
-3
@@ -25,6 +25,7 @@
|
||||
#include <shlobj.h>
|
||||
#include <psapi.h>
|
||||
#include <tlhelp32.h>
|
||||
#include <aclapi.h> // SetEntriesInAcl / EXPLICIT_ACCESS — owner-only DACL for restricted writes
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
@@ -361,15 +362,54 @@ bool Platform::writeFileAtomically(const std::string& path,
|
||||
const std::string tmp = path + ".tmp";
|
||||
|
||||
#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
|
||||
// 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
|
||||
// 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.
|
||||
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);
|
||||
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;
|
||||
size_t written = 0;
|
||||
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.
|
||||
if (ok && !FlushFileBuffers(h)) ok = false;
|
||||
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; }
|
||||
}
|
||||
(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
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
// 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").
|
||||
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;
|
||||
s.reserve(walletFile.size());
|
||||
for (unsigned char c : walletFile)
|
||||
@@ -58,11 +62,18 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
||||
char suffix[9];
|
||||
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
@@ -101,6 +102,9 @@ private:
|
||||
const uint8_t* salt, size_t saltLen,
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||
: api_(other.api_),
|
||||
unavailableReason_(std::move(other.unavailableReason_)),
|
||||
shutdownCalled_(other.shutdownCalled_),
|
||||
shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
|
||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||
{
|
||||
other.api_ = {};
|
||||
other.shutdownCalled_ = true;
|
||||
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||
shutdown();
|
||||
api_ = other.api_;
|
||||
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),
|
||||
std::memory_order_release);
|
||||
other.api_ = {};
|
||||
other.shutdownCalled_ = true;
|
||||
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
|
||||
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (isShutdown()) return shutDownResult();
|
||||
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
||||
bool overwrite)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (isShutdown()) return shutDownResult();
|
||||
return takeOwnedString(api_.initializeNewFromPhrase(
|
||||
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)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (isShutdown()) return shutDownResult();
|
||||
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 (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()));
|
||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
||||
|
||||
void LiteClientBridge::shutdown()
|
||||
{
|
||||
if (shutdownCalled_) return;
|
||||
shutdownCalled_ = true;
|
||||
if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
|
||||
if (available()) api_.shutdown();
|
||||
}
|
||||
|
||||
LiteBridgeStringResult LiteClientBridge::shutDownResult()
|
||||
{
|
||||
return {false, {}, "lite client was shut down"};
|
||||
}
|
||||
|
||||
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
||||
{
|
||||
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
||||
|
||||
@@ -70,6 +70,10 @@ public:
|
||||
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||
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
|
||||
// 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);
|
||||
|
||||
LiteBridgeStringResult unavailableResult() const;
|
||||
static LiteBridgeStringResult shutDownResult();
|
||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||
static bool commandIsReadOnly(const std::string& command);
|
||||
|
||||
LiteClientBridgeApi api_;
|
||||
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};
|
||||
};
|
||||
|
||||
|
||||
@@ -40,7 +40,8 @@ LiteBridgeOwnedString::LiteBridgeOwnedString(LiteBridgeOwnedString&& other) noex
|
||||
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
||||
{
|
||||
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();
|
||||
wipeString(copiedValue_);
|
||||
moveFrom(std::move(other));
|
||||
@@ -49,7 +50,8 @@ LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(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();
|
||||
wipeString(copiedValue_);
|
||||
}
|
||||
@@ -66,13 +68,16 @@ LiteBridgeStringResult LiteBridgeOwnedString::intoResult()
|
||||
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();
|
||||
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_);
|
||||
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
||||
return {true, resultValue, {}};
|
||||
return {true, std::move(resultValue), {}};
|
||||
}
|
||||
|
||||
void LiteBridgeOwnedString::copyValueBeforeFree()
|
||||
|
||||
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
|
||||
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)
|
||||
{
|
||||
try {
|
||||
@@ -95,6 +98,9 @@ bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
||||
if (value.is_string()) {
|
||||
const auto text = value.get<std::string>();
|
||||
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;
|
||||
const auto parsed = std::stoull(text, &parsedSize, 10);
|
||||
if (parsedSize != text.size()) return false;
|
||||
@@ -125,6 +131,8 @@ bool jsonToSigned(const json& value, std::int64_t& output)
|
||||
if (value.is_string()) {
|
||||
const auto text = value.get<std::string>();
|
||||
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;
|
||||
const auto parsed = std::stoll(text, &parsedSize, 10);
|
||||
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 (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, 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, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -337,15 +356,37 @@ bool parseTransactionRecord(const json& value,
|
||||
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
||||
LiteTransactionOutput parsedOutput;
|
||||
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.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;
|
||||
}
|
||||
|
||||
output.direction = LiteTransactionDirection::Receive;
|
||||
if (!readRequiredStringField(value, "address", output.address, 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 (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
||||
return true;
|
||||
@@ -561,11 +602,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
||||
|
||||
// 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"}.
|
||||
std::string syncing = "false";
|
||||
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
|
||||
std::optional<std::string> syncingField;
|
||||
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 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, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
||||
result.syncStatus.complete = false; // actively syncing
|
||||
|
||||
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
|
||||
std::uint64_t totalBlocks = 0;
|
||||
double progress = 0.0;
|
||||
bool complete = false;
|
||||
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
|
||||
};
|
||||
|
||||
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
||||
|
||||
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
||||
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
|
||||
// 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.
|
||||
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
|
||||
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.
|
||||
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");
|
||||
if (op.ok) {
|
||||
scan->setError(std::string());
|
||||
@@ -358,13 +378,38 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
||||
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
||||
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
|
||||
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
|
||||
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
|
||||
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
|
||||
state.spendablePrivateBalance = state.privateBalance;
|
||||
state.spendableTransparentBalance = state.transparentBalance;
|
||||
state.spendableTotalBalance = state.totalBalance;
|
||||
// Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
|
||||
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
|
||||
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
|
||||
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
|
||||
{
|
||||
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
|
||||
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) {
|
||||
@@ -373,10 +418,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
// 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.
|
||||
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
||||
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
||||
if (model.hasSpendableOutputs) {
|
||||
for (const auto& output : model.spendableOutputs) {
|
||||
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
||||
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
|
||||
// like fund loss and breaks "send from this address".
|
||||
std::unordered_map<std::string, double> priorBalances;
|
||||
std::unordered_map<std::string, double> priorSpendable;
|
||||
std::unordered_map<std::string, bool> priorHasKey;
|
||||
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();
|
||||
@@ -404,9 +457,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
} else {
|
||||
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.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) {
|
||||
state.z_addresses.push_back(info);
|
||||
} else {
|
||||
@@ -432,7 +498,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
} else {
|
||||
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.address = record.address;
|
||||
tx.memo = record.memo;
|
||||
@@ -912,9 +980,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
|
||||
if (op == PendingSecurityOp::Encrypt) {
|
||||
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||
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: " +
|
||||
enc.error);
|
||||
}
|
||||
} else if (op == PendingSecurityOp::Unlock) {
|
||||
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||
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
|
||||
// "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.
|
||||
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", "");
|
||||
if (h.ok) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
|
||||
return model;
|
||||
}
|
||||
|
||||
@@ -1235,6 +1317,14 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
|
||||
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 out;
|
||||
@@ -1243,13 +1333,18 @@ LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& opti
|
||||
return out;
|
||||
}
|
||||
// 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) {
|
||||
out.error = result.error.empty() ? "export failed" : result.error;
|
||||
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 {
|
||||
const auto j = nlohmann::json::parse(result.value);
|
||||
j = nlohmann::json::parse(result.value);
|
||||
if (j.is_object() && j.contains("error")) {
|
||||
out.error = extractJsonError(j);
|
||||
return out;
|
||||
@@ -1270,13 +1365,18 @@ LiteSeedResult LiteWalletController::exportSeed()
|
||||
out.error = "no wallet is open";
|
||||
return out;
|
||||
}
|
||||
const auto result = bridge_->execute("seed", "");
|
||||
auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
|
||||
if (!result.ok) {
|
||||
out.error = result.error.empty() ? "seed export failed" : result.error;
|
||||
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 {
|
||||
const auto j = nlohmann::json::parse(result.value);
|
||||
j = nlohmann::json::parse(result.value);
|
||||
if (j.is_object()) {
|
||||
if (j.contains("error")) {
|
||||
out.error = extractJsonError(j);
|
||||
@@ -1541,6 +1641,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool LiteWalletController::takeEncryptionFailureNotice()
|
||||
{
|
||||
const bool failed = lifecycleEncryptFailed_;
|
||||
lifecycleEncryptFailed_ = false;
|
||||
return failed;
|
||||
}
|
||||
|
||||
bool LiteWalletController::lockWallet()
|
||||
{
|
||||
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
|
||||
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()) {
|
||||
if (walletOpen_.load()) {
|
||||
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
|
||||
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
||||
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
|
||||
// Sample the change baselines BEFORE the data commands (race rule).
|
||||
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
||||
@@ -1617,6 +1738,7 @@ void LiteWalletController::workerLoop()
|
||||
gate.encrypted = m.encrypted;
|
||||
gate.locked = m.locked;
|
||||
gate.fullAt = std::chrono::steady_clock::now();
|
||||
lastModel = m;
|
||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||
pendingModel_ = std::move(model);
|
||||
} else {
|
||||
@@ -1696,7 +1818,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
|
||||
// copy and wipes it.
|
||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||
const auto enc = encryptWallet(request.passphrase);
|
||||
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
|
||||
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);
|
||||
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 (walletOpen_.load() && !request.passphrase.empty()) {
|
||||
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.passphrase);
|
||||
|
||||
@@ -363,6 +363,9 @@ public:
|
||||
bool lockWallet();
|
||||
LiteEncryptionResult decryptWallet(std::string passphrase);
|
||||
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
|
||||
// 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 };
|
||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||
bool lifecycleEncryptFailed_ = false; // main-thread only; see takeEncryptionFailureNotice()
|
||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||
|
||||
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
|
||||
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)) {
|
||||
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
||||
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<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_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_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
|
||||
@@ -261,6 +262,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
||||
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
||||
if (std::strcmp(c, "encrypt") == 0) {
|
||||
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
||||
if (g_liteFakeEncryptFails.load())
|
||||
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
|
||||
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
||||
return liteFakeDup("{\"result\":\"success\"}");
|
||||
}
|
||||
|
||||
+572
-6
@@ -819,6 +819,41 @@ void testClassifyRpcFailure()
|
||||
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()
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
@@ -1033,7 +1068,17 @@ void testPaymentUri()
|
||||
|
||||
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
||||
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()
|
||||
@@ -1198,10 +1243,35 @@ void testWalletFileProbe()
|
||||
|
||||
void testAmountFormatting()
|
||||
{
|
||||
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
||||
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
|
||||
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
|
||||
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
|
||||
using namespace dragonx::util;
|
||||
EXPECT_EQ(formatAmountFixed(1.0), std::string("1.00000000"));
|
||||
EXPECT_EQ(formatAmountFixed(0.1), std::string("0.10000000"));
|
||||
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()
|
||||
@@ -2940,6 +3010,88 @@ void testNetworkRefreshResultModels()
|
||||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||||
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()
|
||||
@@ -3656,6 +3808,33 @@ void testNodeStatusBanner()
|
||||
NodeBannerInputs open; open.lite = true; open.connected = true;
|
||||
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()
|
||||
@@ -3776,6 +3955,24 @@ void testSeedMigrationResume()
|
||||
// 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_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()
|
||||
@@ -8985,6 +9182,26 @@ void testExplorerBlockCache()
|
||||
EXPECT_TRUE(cache.storeBlock(block12));
|
||||
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());
|
||||
EXPECT_TRUE(reopened.ensureOpen());
|
||||
@@ -10886,6 +11103,66 @@ void testLiteWalletControllerCreateEncryptsWithPassphrase()
|
||||
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()
|
||||
{
|
||||
using namespace dragonx::wallet;
|
||||
@@ -11216,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()
|
||||
{
|
||||
using dragonx::ui::history::groupByTxid;
|
||||
@@ -11509,8 +11851,9 @@ void testLiteRefreshModelAppliesToWalletState()
|
||||
|
||||
bundle.hasBalance = true;
|
||||
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.unconfirmedBalance = 50000000; // 0.5 DRGX
|
||||
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX (mempool-incoming)
|
||||
|
||||
bundle.hasAddresses = true;
|
||||
bundle.addresses.zAddresses = {"zs1shielded"};
|
||||
@@ -11666,6 +12009,120 @@ void testLiteSendShowsRecipientFromOutgoing()
|
||||
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
|
||||
// !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.
|
||||
@@ -11702,6 +12159,67 @@ void testLitePartialRefreshKeepsPriorAddressBalances()
|
||||
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()
|
||||
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
||||
void testLiteWalletControllerRefreshPopulatesState()
|
||||
@@ -11766,6 +12284,24 @@ void testLiteSyncStatusParserRealShapes()
|
||||
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
||||
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)
|
||||
@@ -11976,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
|
||||
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
||||
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
||||
@@ -19100,6 +19656,7 @@ int main()
|
||||
{
|
||||
testConnectionConfig();
|
||||
testClassifyRpcFailure();
|
||||
testSendMaybeInFlight();
|
||||
testClassifyUnlockFailure();
|
||||
testHttpSinkAccept();
|
||||
testReadFileExact();
|
||||
@@ -19311,18 +19868,25 @@ int main()
|
||||
testLiteWalletControllerM5Persistence();
|
||||
testLiteWalletControllerEncryption();
|
||||
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||||
testLiteWalletControllerLockUnlockParity();
|
||||
testLiteWalletControllerAsyncAppliesPassphrase();
|
||||
testLiteNewPassphraseRules();
|
||||
testLiteChainNameMigration();
|
||||
testLiteUnconfirmedAndTransparentSpendable();
|
||||
testLiteRefreshModelAppliesToWalletState();
|
||||
testLiteTransactionsAreNewestFirst();
|
||||
testSameTransactionList();
|
||||
testTxStructureRevision();
|
||||
testHistoryTxidGroups();
|
||||
testHistoryFilterInputs();
|
||||
testWalletStateTxRevision();
|
||||
testTransactionRefreshRevision();
|
||||
testLiteSendShowsRecipientFromOutgoing();
|
||||
testLiteSendAmountIsNegative();
|
||||
testLiteForeignSendFallsBackToRecordAmount();
|
||||
testLiteParserAmountRangeChecks();
|
||||
testLitePartialRefreshKeepsPriorAddressBalances();
|
||||
testLitePerAddressSpendableAndViewOnly();
|
||||
testLitePerAddressBalances();
|
||||
testLiteWalletControllerNewAddress();
|
||||
testLiteSyncStatusParserRealShapes();
|
||||
@@ -19331,6 +19895,7 @@ int main()
|
||||
testLiteWalletControllerWorkerProducesModel();
|
||||
testLiteWalletControllerWorkerChangeGate();
|
||||
testLiteClientBridgeMutationEpoch();
|
||||
testLiteClientBridgeRefusesAfterShutdown();
|
||||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||||
testSerialJobLane();
|
||||
testLiteGatewayRefreshPolicyHelpers();
|
||||
@@ -19425,6 +19990,7 @@ int main()
|
||||
testResolveExchangeSelection();
|
||||
testTransactionCsv();
|
||||
testEarningsSeries();
|
||||
testResilienceMediums();
|
||||
|
||||
if (g_failures != 0) {
|
||||
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
|
||||
// new branch. Only a block that spends the note (scan_block) or a reload clears
|
||||
// it, the same as for a send that expires unmined.
|
||||
})
|
||||
// new branch. Only a block that spends the note (scan_block), the send expiring
|
||||
// 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
|
||||
pub fn cleanup_mempool(&self) {
|
||||
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 mut expired: HashSet<TxId> = HashSet::new();
|
||||
{
|
||||
// Remove all expired Txns
|
||||
self.mempool_txs.write().unwrap().retain( | _, wtx| {
|
||||
current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA)
|
||||
// Remove all expired Txns, remembering which ones so their inputs can be released below.
|
||||
self.mempool_txs.write().unwrap().retain( | txid, wtx| {
|
||||
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
|
||||
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 memo: Option<Memo>,
|
||||
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();
|
||||
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
||||
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))
|
||||
.cloned().collect();
|
||||
|
||||
Reference in New Issue
Block a user