Compare commits
36
Commits
8961cbdfc9
..
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 |
@@ -48,6 +48,36 @@ for f in "${files[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Content guards (UX-hygiene threads) ──────────────────────────────────────
|
||||||
|
# Low-false-positive regression checks for debt the UX audit cleared, so it can't
|
||||||
|
# silently regrow. Each is deliberately narrow; widen only with an allowlist.
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
[[ "$f" == *.cpp ]] || continue
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
# app_sweep.cpp is the dev screenshot-sweep harness: its fixture strings are never shipped UI.
|
||||||
|
[[ "$(basename "$f")" == "app_sweep.cpp" ]] && continue
|
||||||
|
|
||||||
|
# (1) Tactile button sized with a raw multi-digit pixel literal that ignores HiDPI.
|
||||||
|
# Real sizes multiply by ui::Layout::dpiScale() / dp / hScale, or come from schema.
|
||||||
|
if hits=$(grep -nE 'Tactile(Small)?Button\([^;]*ImVec2\([1-9][0-9]+(\.[0-9]+f?)?,' "$f" \
|
||||||
|
| grep -vE 'dpiScale|\* *dp\b|\* *hdpi|hScale|schema|Scale\('); then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "✗ unscaled button size (multiply the px by ui::Layout::dpiScale()): $f:$line" >&2
|
||||||
|
fail=1
|
||||||
|
done <<< "$hits"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (2) User-visible migrate/sweep status assigned a bare English literal instead of TR(...).
|
||||||
|
# "__SENTINEL__" comparisons and empty strings are fine.
|
||||||
|
if hits=$(grep -nE '(sweep_status_|seed_migration_status_)[[:space:]]*=[[:space:]]*"[A-Za-z]' "$f" \
|
||||||
|
| grep -vE 'TR\(|"__'); then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "✗ untranslated status string (route through TR(...)): $f:$line" >&2
|
||||||
|
fail=1
|
||||||
|
done <<< "$hits"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if (( fail )); then
|
if (( fail )); then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
||||||
|
|||||||
+183
-24
@@ -40,6 +40,7 @@
|
|||||||
#include "ui/windows/faq_dialog.h"
|
#include "ui/windows/faq_dialog.h"
|
||||||
#include "embedded/IconsMaterialDesign.h"
|
#include "embedded/IconsMaterialDesign.h"
|
||||||
#include "ui/windows/key_export_dialog.h"
|
#include "ui/windows/key_export_dialog.h"
|
||||||
|
#include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers
|
||||||
#include "ui/windows/transaction_details_dialog.h"
|
#include "ui/windows/transaction_details_dialog.h"
|
||||||
#include "ui/windows/qr_popup_dialog.h"
|
#include "ui/windows/qr_popup_dialog.h"
|
||||||
#include "ui/windows/validate_address_dialog.h"
|
#include "ui/windows/validate_address_dialog.h"
|
||||||
@@ -143,6 +144,28 @@ void App::wipeSecrets()
|
|||||||
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
||||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
||||||
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
|
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
|
||||||
|
ui::scrubImGuiInputTextState(); // zero ImGui's retained copies of whatever secret was last typed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub + close any secret that is currently ON SCREEN (revealed seed phrase, migration seed, exported
|
||||||
|
// key) when the wallet locks — locking shouldn't leave a phrase/key visible or resident. Called on every
|
||||||
|
// lock transition (manual, auto, lite). Separate from wipeSecrets(), which is the exit-path scrub.
|
||||||
|
void App::wipeDisplayedSecrets()
|
||||||
|
{
|
||||||
|
if (!seed_backup_phrase_.empty()) {
|
||||||
|
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
|
||||||
|
seed_backup_phrase_.clear();
|
||||||
|
}
|
||||||
|
if (!seed_migration_seed_.empty()) {
|
||||||
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||||
|
seed_migration_seed_.clear();
|
||||||
|
}
|
||||||
|
seed_backup_fetch_started_ = false;
|
||||||
|
show_seed_backup_ = false;
|
||||||
|
sodium_memzero(export_result_, sizeof(export_result_));
|
||||||
|
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
|
||||||
|
console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console
|
||||||
|
ui::scrubImGuiInputTextState(); // zero ImGui's retained copy of the last-typed passphrase/PIN
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
||||||
@@ -942,6 +965,9 @@ void App::update()
|
|||||||
lite_wallet_->pumpAsyncOpen();
|
lite_wallet_->pumpAsyncOpen();
|
||||||
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
||||||
lite_wallet_->pumpLifecycleResult();
|
lite_wallet_->pumpLifecycleResult();
|
||||||
|
// A create/restore whose passphrase encryption failed left the wallet UNENCRYPTED (previously log-only): say so.
|
||||||
|
if (lite_wallet_->takeEncryptionFailureNotice())
|
||||||
|
ui::Notifications::instance().error(TR("lite_encrypt_failed_unencrypted"), 15.0f);
|
||||||
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
||||||
lite_wallet_->pumpWalletOps();
|
lite_wallet_->pumpWalletOps();
|
||||||
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
||||||
@@ -1027,7 +1053,11 @@ void App::update()
|
|||||||
case LiteOpKind::None:
|
case LiteOpKind::None:
|
||||||
default:
|
default:
|
||||||
if (lite_send_callback_) {
|
if (lite_send_callback_) {
|
||||||
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
// A transport-style failure may mean the server accepted the tx but the reply was
|
||||||
|
// lost: tag it so the Send tab says "check History" instead of inviting a resend.
|
||||||
|
const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error))
|
||||||
|
? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error;
|
||||||
|
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr);
|
||||||
lite_send_callback_ = nullptr;
|
lite_send_callback_ = nullptr;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -2392,6 +2422,8 @@ void App::render()
|
|||||||
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||||
else
|
else
|
||||||
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
||||||
|
// One-time: route the console's "Copy secret" through the auto-clearing clipboard.
|
||||||
|
console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); });
|
||||||
}
|
}
|
||||||
console_tab_.render(*console_exec_);
|
console_tab_.render(*console_exec_);
|
||||||
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
||||||
@@ -2550,7 +2582,11 @@ void App::render()
|
|||||||
if (show_import_key_) {
|
if (show_import_key_) {
|
||||||
renderImportKeyDialog();
|
renderImportKeyDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (show_import_wallet_file_) {
|
||||||
|
renderImportWalletFileDialog();
|
||||||
|
}
|
||||||
|
|
||||||
if (show_export_key_) {
|
if (show_export_key_) {
|
||||||
renderExportKeyDialog();
|
renderExportKeyDialog();
|
||||||
}
|
}
|
||||||
@@ -2684,6 +2720,10 @@ void App::renderNodeStatusBanner()
|
|||||||
in.node_busy = busy.busy;
|
in.node_busy = busy.busy;
|
||||||
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
||||||
in.auth_failed = rpc_auth_failed_;
|
in.auth_failed = rpc_auth_failed_;
|
||||||
|
// Corrected trigger: ONLY a degraded wallet (wallet_degraded_ — z_getnewaddress fails, can't shield) that
|
||||||
|
// actually holds transparent funds. A merely-non-mnemonic but functional wallet has wallet_degraded_ ==
|
||||||
|
// false and is never flagged here. Full-node only (in.lite returns early regardless).
|
||||||
|
in.legacy_funds_stuck = !isLiteBuild() && wallet_degraded_ && state_.transparentBalance > 0.0001;
|
||||||
|
|
||||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||||
if (!banner.show) return;
|
if (!banner.show) return;
|
||||||
@@ -2722,6 +2762,8 @@ void App::renderNodeStatusBanner()
|
|||||||
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
||||||
case ui::NodeBannerReason::AuthFailed:
|
case ui::NodeBannerReason::AuthFailed:
|
||||||
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
||||||
|
case ui::NodeBannerReason::LegacyFundsStuck:
|
||||||
|
title = TR("node_banner_legacy_title"); icon = ICON_MD_KEY; break;
|
||||||
case ui::NodeBannerReason::FullNodeOffline:
|
case ui::NodeBannerReason::FullNodeOffline:
|
||||||
default:
|
default:
|
||||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
@@ -2731,6 +2773,7 @@ void App::renderNodeStatusBanner()
|
|||||||
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
||||||
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
||||||
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) actionLabel = TR("node_banner_recover");
|
||||||
|
|
||||||
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
||||||
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
||||||
@@ -2738,6 +2781,7 @@ void App::renderNodeStatusBanner()
|
|||||||
std::string detailText;
|
std::string detailText;
|
||||||
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
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::AuthFailed) detailText = TR("node_banner_auth_detail");
|
||||||
|
else if (banner.reason == ui::NodeBannerReason::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail");
|
||||||
else detailText = banner.detail;
|
else detailText = banner.detail;
|
||||||
|
|
||||||
const float padX = ui::Layout::spacingLg();
|
const float padX = ui::Layout::spacingLg();
|
||||||
@@ -2827,6 +2871,7 @@ void App::renderNodeStatusBanner()
|
|||||||
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
||||||
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
||||||
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog();
|
||||||
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
||||||
const std::string conf = rpc::Connection::getDefaultConfPath();
|
const std::string conf = rpc::Connection::getDefaultConfPath();
|
||||||
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
||||||
@@ -3081,6 +3126,10 @@ void App::renderStatusBar()
|
|||||||
} else if (rpc_busy_gate_->busy()) {
|
} else if (rpc_busy_gate_->busy()) {
|
||||||
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
||||||
|
} else if (isLiteBuild() && !state_.sync.lite_error.empty()) {
|
||||||
|
// Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'.
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"),
|
||||||
|
state_.sync.lite_error.c_str());
|
||||||
} else if (state_.sync.syncing) {
|
} else if (state_.sync.syncing) {
|
||||||
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
||||||
int blocksLeft = chainTip - state_.sync.blocks;
|
int blocksLeft = chainTip - state_.sync.blocks;
|
||||||
@@ -3464,14 +3513,20 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
||||||
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||||
|
|
||||||
|
// Best-effort zero of the seed-word statics (they hold the whole phrase) before they're dropped.
|
||||||
|
auto wipeStr = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
auto wipeWords = [&](std::vector<std::string>& v) { for (auto& w : v) wipeStr(w); v.clear(); };
|
||||||
|
auto wipeChips = [&](std::vector<std::pair<std::string, bool>>& v) {
|
||||||
|
for (auto& c : v) { wipeStr(c.first); c.second = false; } v.clear();
|
||||||
|
};
|
||||||
auto finish = [&]() {
|
auto finish = [&]() {
|
||||||
wallet::secureWipeLiteSecret(seed);
|
wallet::secureWipeLiteSecret(seed);
|
||||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||||
restoreBirthday = 0;
|
restoreBirthday = 0;
|
||||||
restoring = false;
|
restoring = false;
|
||||||
restoreErr.clear();
|
restoreErr.clear();
|
||||||
words.clear();
|
wipeWords(words);
|
||||||
chips.clear();
|
wipeChips(chips);
|
||||||
progress = 0;
|
progress = 0;
|
||||||
step = 0;
|
step = 0;
|
||||||
skipConfirm = false;
|
skipConfirm = false;
|
||||||
@@ -3579,7 +3634,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::Spacing(); ImGui::Spacing();
|
ImGui::Spacing(); ImGui::Spacing();
|
||||||
|
|
||||||
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
|
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
|
||||||
chips.clear();
|
wipeChips(chips);
|
||||||
for (const auto& w : words) chips.emplace_back(w, false);
|
for (const auto& w : words) chips.emplace_back(w, false);
|
||||||
std::mt19937 rng{std::random_device{}()};
|
std::mt19937 rng{std::random_device{}()};
|
||||||
std::shuffle(chips.begin(), chips.end(), rng);
|
std::shuffle(chips.begin(), chips.end(), rng);
|
||||||
@@ -3590,7 +3645,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
skipConfirm = true; // require a second, deliberate click
|
skipConfirm = true; // require a second, deliberate click
|
||||||
} else {
|
} else {
|
||||||
@@ -3653,9 +3708,9 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
if (!verified) ImGui::EndDisabled();
|
if (!verified) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; }
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) { skipConfirm = false; step = 1; }
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
skipConfirm = true; // require a second, deliberate click
|
skipConfirm = true; // require a second, deliberate click
|
||||||
} else {
|
} else {
|
||||||
@@ -3752,7 +3807,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) {
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||||
restoreErr.clear();
|
restoreErr.clear();
|
||||||
step = 0;
|
step = 0;
|
||||||
@@ -3790,12 +3845,13 @@ void App::renderLiteUnlockPrompt()
|
|||||||
const float btnW = 130.0f * dp;
|
const float btnW = 130.0f * dp;
|
||||||
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
||||||
if (doUnlock) {
|
if (doUnlock) {
|
||||||
const bool ok = lite_wallet_->unlockWallet(pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = unlockLiteWallet(std::string(pass), &unlockErr);
|
||||||
sodium_memzero(pass, sizeof(pass));
|
sodium_memzero(pass, sizeof(pass));
|
||||||
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
||||||
else {
|
else {
|
||||||
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
|
wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
|
||||||
ui::Notifications::instance().error(TR("lite_unlock_failed"));
|
ui::Notifications::instance().error(unlockErr);
|
||||||
}
|
}
|
||||||
lite_unlock_prompt_ = false;
|
lite_unlock_prompt_ = false;
|
||||||
ImGui::CloseCurrentPopup();
|
ImGui::CloseCurrentPopup();
|
||||||
@@ -3897,6 +3953,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::renderImportWalletFileDialog()
|
||||||
|
{
|
||||||
|
namespace m = ui::material;
|
||||||
|
float dp = ui::Layout::dpiScale();
|
||||||
|
|
||||||
|
m::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("import_wallet_file_title");
|
||||||
|
ov.p_open = &show_import_wallet_file_;
|
||||||
|
ov.style = m::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 640.0f;
|
||||||
|
ov.idSuffix = "importwalletfile";
|
||||||
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
|
if (ImGui::IsWindowAppearing()) {
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_ok_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
}
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && !import_wallet_file_busy_) show_import_wallet_file_ = false;
|
||||||
|
|
||||||
|
m::DialogWarningHeader(TR("import_wallet_file_note"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (!state_.connected) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextUnformatted(TR("import_wallet_file_path_label"));
|
||||||
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
ImGui::InputText("##importwalletpath", import_wallet_file_path_, sizeof(import_wallet_file_path_));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
if (!import_wallet_file_busy_) {
|
||||||
|
if (m::TactileButton(TR("paste"), ImVec2(104 * dp, 0))) {
|
||||||
|
const char* clip = ImGui::GetClipboardText();
|
||||||
|
if (clip) {
|
||||||
|
std::string t = dragonx::util::trimmedClipboardText(clip);
|
||||||
|
snprintf(import_wallet_file_path_, sizeof(import_wallet_file_path_), "%s", t.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (m::TactileButton(TR("clear"), ImVec2(104 * dp, 0))) import_wallet_file_path_[0] = '\0';
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
if (import_wallet_file_busy_) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s%s",
|
||||||
|
TR("import_wallet_file_importing"), m::LoadingDots());
|
||||||
|
ImGui::Spacing();
|
||||||
|
} else if (import_wallet_file_done_) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(
|
||||||
|
import_wallet_file_ok_ ? m::Success() : m::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", import_wallet_file_status_.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_ || !state_.connected || import_wallet_file_path_[0] == '\0');
|
||||||
|
if (m::TactileButton(TR("import_wallet_file_import_btn"), ImVec2(190 * dp, 0))) {
|
||||||
|
import_wallet_file_busy_ = true;
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
importWalletFile(import_wallet_file_path_, [this](bool ok, const std::string& err) {
|
||||||
|
import_wallet_file_busy_ = false;
|
||||||
|
import_wallet_file_done_ = true;
|
||||||
|
import_wallet_file_ok_ = ok;
|
||||||
|
import_wallet_file_status_ = ok ? std::string(TR("import_wallet_file_ok")) : err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
if (m::TactileButton(TR("close"), ImVec2(110 * dp, 0))) show_import_wallet_file_ = false;
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
m::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
|
||||||
void App::renderImportKeyDialog()
|
void App::renderImportKeyDialog()
|
||||||
{
|
{
|
||||||
namespace m = ui::material;
|
namespace m = ui::material;
|
||||||
@@ -4324,8 +4459,11 @@ void App::renderExportKeyDialog()
|
|||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
||||||
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
||||||
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
||||||
if (m::TactileButton(TR("copy")))
|
if (m::TactileButton(TR("copy"))) {
|
||||||
ImGui::SetClipboardText(export_result_);
|
// A private key is a secret — route through the auto-clearing clipboard path (45s armed wipe
|
||||||
|
// + its own "copied, auto-clears" toast) instead of a plain, permanent clipboard write.
|
||||||
|
copySecretToClipboard(export_result_);
|
||||||
|
}
|
||||||
} else if (export_error_) {
|
} else if (export_error_) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -4709,7 +4847,7 @@ void App::renderSeedBackupDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
|
{ auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_backup_status_.empty()) {
|
if (!seed_backup_status_.empty()) {
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -4879,7 +5017,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
||||||
{
|
{
|
||||||
ui::material::GlassSectionScope seedPanel;
|
ui::material::GlassSectionScope seedPanel;
|
||||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
|
{ auto words = ui::SplitSeedWords(seed_migration_seed_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
||||||
@@ -5001,7 +5139,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // fresh minconf-0, sync-aware balance check before the swap
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
@@ -5026,6 +5164,22 @@ void App::renderSeedMigrationDialog()
|
|||||||
{
|
{
|
||||||
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
||||||
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
|
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 (!seed_migration_confirm_sweep_) {
|
||||||
if (!canSweep) ImGui::BeginDisabled();
|
if (!canSweep) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
||||||
@@ -5065,7 +5219,8 @@ void App::renderSeedMigrationDialog()
|
|||||||
|
|
||||||
case SeedMigrationStep::Confirming: {
|
case SeedMigrationStep::Confirming: {
|
||||||
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
||||||
const bool confirmed = seed_migration_sweep_confs_ >= 1;
|
const bool mined = seed_migration_sweep_confs_ >= 1; // remainder re-sweep stays offered once mined
|
||||||
|
const bool confirmed = dragonx::sweepConfsAreReorgSafe(seed_migration_sweep_confs_); // adopt needs reorg-safe depth
|
||||||
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
||||||
seed_migration_legacy_remaining_ <= dust;
|
seed_migration_legacy_remaining_ <= dust;
|
||||||
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
||||||
@@ -5085,8 +5240,8 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (confirmed && legacyEmpty) {
|
if (confirmed && legacyEmpty) {
|
||||||
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
|
||||||
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
} else if (mined && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
||||||
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
||||||
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
||||||
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
||||||
@@ -5110,6 +5265,10 @@ void App::renderSeedMigrationDialog()
|
|||||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
seed_migration_confirm_sweep_ = false;
|
seed_migration_confirm_sweep_ = false;
|
||||||
}
|
}
|
||||||
|
} else if (mined && legacyEmpty) {
|
||||||
|
// Mined but not yet reorg-safe deep: say so instead of the misleading "waiting to be mined".
|
||||||
|
char dbuf[200]; snprintf(dbuf, sizeof(dbuf), TR("mig_waiting_depth"), dragonx::kSweepAdoptMinConfs);
|
||||||
|
ImGui::TextWrapped("%s", dbuf);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
||||||
}
|
}
|
||||||
@@ -6038,16 +6197,16 @@ bool App::startEmbeddedDaemon()
|
|||||||
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case daemon::EmbeddedDaemon::State::Starting:
|
case daemon::EmbeddedDaemon::State::Starting:
|
||||||
setDaemonStatus(TR("sb_starting_daemon"));
|
setDaemonStatus(util::trCopy("sb_starting_daemon"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Running:
|
case daemon::EmbeddedDaemon::State::Running:
|
||||||
setDaemonStatus(TR("sb_dragonxd_running"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_running"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopping:
|
case daemon::EmbeddedDaemon::State::Stopping:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopped:
|
case daemon::EmbeddedDaemon::State::Stopped:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Error:
|
case daemon::EmbeddedDaemon::State::Error:
|
||||||
setDaemonStatus("Error: " + msg);
|
setDaemonStatus("Error: " + msg);
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ class App {
|
|||||||
public:
|
public:
|
||||||
App();
|
App();
|
||||||
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
||||||
|
void wipeDisplayedSecrets(); // scrub + close any on-screen secret (seed/key displays) on wallet lock
|
||||||
~App();
|
~App();
|
||||||
|
|
||||||
// Non-copyable
|
// Non-copyable
|
||||||
@@ -332,6 +333,10 @@ public:
|
|||||||
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
||||||
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
||||||
bool lockLiteWallet();
|
bool lockLiteWallet();
|
||||||
|
// Unlock the lite wallet with the same escalating attempt-lockout as the full node (persisted across restarts)
|
||||||
|
// and set state_.locked=false right away on success (the poll lags). Takes the passphrase by value and wipes
|
||||||
|
// it. On failure *err (if given) holds a user-facing reason ("Unlock failed..." or the lockout wait).
|
||||||
|
bool unlockLiteWallet(std::string passphrase, std::string* err = nullptr);
|
||||||
// (Re)build the lite controller from current settings so a changed lite-server selection
|
// (Re)build the lite controller from current settings so a changed lite-server selection
|
||||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||||
void rebuildLiteWallet(bool force = false);
|
void rebuildLiteWallet(bool force = false);
|
||||||
@@ -448,6 +453,9 @@ public:
|
|||||||
// Address operations
|
// Address operations
|
||||||
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
|
// The daemon's actual reason the last new-address RPC failed (friendly-mapped), or "" on success. Lets
|
||||||
|
// the UI show e.g. "HD seed not found" (legacy/degraded wallet) instead of a generic "couldn't create".
|
||||||
|
const std::string& lastAddressError() const { return last_address_error_; }
|
||||||
|
|
||||||
// Hide/unhide addresses from the address list (persisted in settings)
|
// Hide/unhide addresses from the address list (persisted in settings)
|
||||||
void hideAddress(const std::string& addr);
|
void hideAddress(const std::string& addr);
|
||||||
@@ -557,10 +565,11 @@ public:
|
|||||||
private:
|
private:
|
||||||
App& app_;
|
App& app_;
|
||||||
};
|
};
|
||||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
|
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
|
||||||
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
|
// maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
|
||||||
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
|
// refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
|
||||||
// 30 s, as before), so a user's own send is never cut off mid-call.
|
// flag is cleared by the next tryConnect(). A user send aborted mid-flight is handled as a lost reply
|
||||||
|
// ("outcome unknown — check History"), never a double-send.
|
||||||
void abortMaintenanceCalls();
|
void abortMaintenanceCalls();
|
||||||
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
||||||
// full node that is past warmup, not rescanning and not busy.
|
// full node that is past warmup, not rescanning and not busy.
|
||||||
@@ -641,6 +650,9 @@ public:
|
|||||||
// Dialog triggers (used by settings page to open modal dialogs)
|
// Dialog triggers (used by settings page to open modal dialogs)
|
||||||
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
||||||
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
||||||
|
void showImportWalletFileDialog() { show_import_wallet_file_ = true; } // import a whole legacy key dump
|
||||||
|
// Import every key from an old wallet's z_exportwallet/dumpwallet dump file (z_importwallet, one rescan).
|
||||||
|
void importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback);
|
||||||
void showExportKeyDialog() { show_export_key_ = true; }
|
void showExportKeyDialog() { show_export_key_ = true; }
|
||||||
void showBackupDialog() { show_backup_ = true; }
|
void showBackupDialog() { show_backup_ = true; }
|
||||||
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
||||||
@@ -1187,6 +1199,7 @@ private:
|
|||||||
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
||||||
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
|
void verifyThenAdoptSeedWallet(); // re-read sweep confs + minconf-0 balance, then beginAdoptSeedWallet() iff safe
|
||||||
void provisionChatIdentityFromSecret(std::string secret);
|
void provisionChatIdentityFromSecret(std::string secret);
|
||||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||||
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
||||||
@@ -1343,6 +1356,7 @@ private:
|
|||||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||||
|
std::string last_address_error_; // daemon reason the last z_getnewaddress/getnewaddress failed ("" = ok)
|
||||||
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||||
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||||
@@ -1408,6 +1422,12 @@ private:
|
|||||||
bool show_about_ = false;
|
bool show_about_ = false;
|
||||||
bool show_faq_ = false;
|
bool show_faq_ = false;
|
||||||
bool show_import_key_ = false;
|
bool show_import_key_ = false;
|
||||||
|
bool show_import_wallet_file_ = false; // the "import keys from a backup file" modal is open
|
||||||
|
char import_wallet_file_path_[1024] = {0}; // typed path to the old wallet's key dump (not a secret)
|
||||||
|
bool import_wallet_file_busy_ = false; // a z_importwallet long call is in flight
|
||||||
|
bool import_wallet_file_done_ = false; // terminal result reached (ok/err in import_wallet_file_ok_)
|
||||||
|
bool import_wallet_file_ok_ = false;
|
||||||
|
std::string import_wallet_file_status_; // result / error text shown in the modal
|
||||||
bool show_export_key_ = false;
|
bool show_export_key_ = false;
|
||||||
bool show_backup_ = false;
|
bool show_backup_ = false;
|
||||||
bool show_seed_backup_ = false;
|
bool show_seed_backup_ = false;
|
||||||
@@ -2049,6 +2069,7 @@ private:
|
|||||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
|
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
|
||||||
void renderExportKeyDialog();
|
void renderExportKeyDialog();
|
||||||
void renderBackupDialog();
|
void renderBackupDialog();
|
||||||
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
||||||
|
|||||||
+346
-49
@@ -41,6 +41,10 @@
|
|||||||
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <fcntl.h> // open/fsync: flush the staged wallet + .bak before the atomic swap
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||||
@@ -559,15 +563,31 @@ void App::tryConnect()
|
|||||||
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
||||||
}
|
}
|
||||||
|
|
||||||
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
// A previous teardown leaves rpc_'s abort flag set (open() no longer clears it, so an abort issued
|
||||||
|
// while this connect is still queued is not erased). This is the reconnect entry point: clear it here
|
||||||
|
// on the main thread, after the daemon_restarting_/shutting_down_ gates above.
|
||||||
|
if (rpc_ && !shutting_down_.load()) rpc_->clearAbort();
|
||||||
|
const std::uint64_t connectSession = rpc_session_; // main thread; a teardown/onConnected bumps it
|
||||||
|
worker_->post([this, config, daemonStarting, externalDetected, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
||||||
std::string connectErr = rpc_->getLastConnectError();
|
std::string connectErr = rpc_->getLastConnectError();
|
||||||
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
||||||
bool warmingUp = rpc_->isWarmingUp();
|
bool warmingUp = rpc_->isWarmingUp();
|
||||||
std::string warmupStatus = rpc_->getWarmupStatus();
|
std::string warmupStatus = rpc_->getWarmupStatus();
|
||||||
|
|
||||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
||||||
connectKind]() {
|
connectKind, connectSession]() {
|
||||||
|
// A probe that succeeded after a wallet switch / adopt / restore / shutdown already tore the
|
||||||
|
// connection down must not run onConnected() against a daemon that is about to be stopped.
|
||||||
|
if (connected && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
VERBOSE_LOGF("[connect #%d] Dropping stale connect result (teardown raced the probe)\n", attempt);
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort(); // a worker call may hold curl_mutex_; don't block the UI on disconnect()
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
connection_in_progress_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
||||||
if (connected) connect_backoff_.reset();
|
if (connected) connect_backoff_.reset();
|
||||||
if (connected) {
|
if (connected) {
|
||||||
@@ -596,12 +616,19 @@ void App::tryConnect()
|
|||||||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
||||||
attempt, cookieConfig.hush_dir.c_str());
|
attempt, cookieConfig.hush_dir.c_str());
|
||||||
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, cookieConfig, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
||||||
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
||||||
cookieConfig.use_tls);
|
cookieConfig.use_tls);
|
||||||
return [this, cookieConfig, ok, attempt]() {
|
return [this, cookieConfig, ok, attempt, connectSession]() {
|
||||||
connection_in_progress_ = false;
|
connection_in_progress_ = false;
|
||||||
|
if (ok && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort();
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
return; // stale: a teardown raced the probe
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
||||||
saved_config_ = cookieConfig;
|
saved_config_ = cookieConfig;
|
||||||
@@ -832,6 +859,7 @@ void App::onConnected()
|
|||||||
fast_worker_->start();
|
fast_worker_->start();
|
||||||
}
|
}
|
||||||
// Connect on the fast worker's own thread (non-blocking to main)
|
// Connect on the fast worker's own thread (non-blocking to main)
|
||||||
|
if (fast_rpc_ && !shutting_down_.load()) fast_rpc_->clearAbort(); // open() no longer clears a prior teardown's abort
|
||||||
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
||||||
saved_config_.rpcuser, saved_config_.rpcpassword,
|
saved_config_.rpcuser, saved_config_.rpcpassword,
|
||||||
@@ -891,8 +919,10 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
pending_opids_.clear();
|
pending_opids_.clear();
|
||||||
pending_send_info_.clear();
|
pending_send_info_.clear();
|
||||||
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
||||||
|
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
|
||||||
|
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
|
||||||
for (auto& entry : pending_send_callbacks_) {
|
for (auto& entry : pending_send_callbacks_) {
|
||||||
if (entry.second) entry.second(false, reason);
|
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
|
||||||
}
|
}
|
||||||
pending_send_callbacks_.clear();
|
pending_send_callbacks_.clear();
|
||||||
consecutive_core_failures_ = 0;
|
consecutive_core_failures_ = 0;
|
||||||
@@ -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
|
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
||||||
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
||||||
fast_worker_.reset();
|
fast_worker_.reset();
|
||||||
|
// stop() discards undrained MainCbs, and these in-progress flags are cleared only inside
|
||||||
|
// those callbacks (unlock/lock/PIN/encrypt are posted on the fast lane when it is running).
|
||||||
|
// Release them here so the lock screen cannot show "Unlocking..." forever; the user can retry
|
||||||
|
// after reconnect, and the real encryption state is re-read on connect.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
pin_in_progress_ = false;
|
||||||
|
encrypt_in_progress_ = false;
|
||||||
}
|
}
|
||||||
if (fast_rpc_) {
|
if (fast_rpc_) {
|
||||||
fast_rpc_->disconnect();
|
fast_rpc_->disconnect();
|
||||||
@@ -1111,7 +1148,10 @@ void App::abortMaintenanceCalls()
|
|||||||
{
|
{
|
||||||
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
||||||
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
||||||
if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight
|
// Unconditional: an ordinary in-flight refresh/scan also holds curl_mutex_ for its whole
|
||||||
|
// curl_easy_perform, and every caller disconnect()s next. The abort is cleared by the next
|
||||||
|
// tryConnect() (rpc_) / onConnected() (fast_rpc_) before they reconnect.
|
||||||
|
if (rpc_) rpc_->requestAbort(); // cuts off whatever call is in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
bool App::walletScanReady() const
|
bool App::walletScanReady() const
|
||||||
@@ -3917,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
|
|||||||
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
||||||
: "Receive tab / New transparent address";
|
: "Receive tab / New transparent address";
|
||||||
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
||||||
std::string addr;
|
std::string addr, genErr;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
json result = rpc_->call(rpcMethod);
|
json result = rpc_->call(rpcMethod);
|
||||||
addr = result.get<std::string>();
|
addr = result.get<std::string>();
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
||||||
|
genErr = e.what(); // keep the real reason so the UI can show it instead of a generic toast
|
||||||
}
|
}
|
||||||
return [this, callback, addr, 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()) {
|
if (!addr.empty()) {
|
||||||
// Inject immediately so UI can select the address next frame
|
// Inject immediately so UI can select the address next frame
|
||||||
AddressInfo info;
|
AddressInfo info;
|
||||||
@@ -5399,6 +5456,7 @@ bool App::lockLiteWallet()
|
|||||||
if (!lite_wallet_) return false;
|
if (!lite_wallet_) return false;
|
||||||
const bool ok = lite_wallet_->lockWallet();
|
const bool ok = lite_wallet_->lockWallet();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
wipeDisplayedSecrets(); // scrub any revealed seed/key display on lock (matches full-node)
|
||||||
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
||||||
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
||||||
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
||||||
@@ -5413,6 +5471,69 @@ bool App::lockLiteWallet()
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool App::unlockLiteWallet(std::string passphrase, std::string* err)
|
||||||
|
{
|
||||||
|
const auto fail = [&](const std::string& m) { if (err) *err = m; };
|
||||||
|
const auto waitMsg = [](long long secs) {
|
||||||
|
char msg[128];
|
||||||
|
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), (double)secs);
|
||||||
|
return std::string(msg);
|
||||||
|
};
|
||||||
|
if (!lite_wallet_ || !lite_wallet_->walletOpen() || passphrase.empty()) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false; // nothing to attempt: not a wrong-credential failure, not counted
|
||||||
|
}
|
||||||
|
// Restore the persisted attempt count once per session (the full-node lock screen does the same).
|
||||||
|
if (!lock_state_restored_) {
|
||||||
|
lock_state_restored_ = true;
|
||||||
|
restoreLockoutState();
|
||||||
|
}
|
||||||
|
// Enforce the lockout from the persisted wall-clock expiry: lite has no per-frame lockout timer.
|
||||||
|
if (settings_ && settings_->getLockStateWallet() == settings_->getActiveWalletFile()) {
|
||||||
|
const long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
|
||||||
|
if (remaining > 0) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(waitMsg(remaining));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock_lockout_timer_ = 0.0f; // expired: don't let a stale in-memory value re-arm the curve
|
||||||
|
|
||||||
|
const bool ok = lite_wallet_->unlockWallet(std::move(passphrase)); // wipes the passphrase
|
||||||
|
if (!ok) {
|
||||||
|
const auto st = lite_wallet_->encryptionStatus();
|
||||||
|
if (st.ok && !(st.encrypted && st.locked)) {
|
||||||
|
// Not a wrong credential: already unlocked (e.g. a racing prompt) or not encrypted. Don't count it.
|
||||||
|
state_.encrypted = st.encrypted;
|
||||||
|
state_.locked = false;
|
||||||
|
if (st.encrypted) { idle_.noteActivity(); return true; }
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
applyUnlockFailure(TR("lite_unlock_failed")); // bump counter, arm lockout curve, persist
|
||||||
|
fail(lock_lockout_timer_ > 0.0f ? waitMsg((long long)std::ceil(lock_lockout_timer_))
|
||||||
|
: std::string(TR("lite_unlock_failed")));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: clear the attempt state (and its persisted copy) and reflect the unlock NOW — the next poll lags.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
lock_error_msg_.clear();
|
||||||
|
lock_attempts_ = 0;
|
||||||
|
lock_lockout_timer_ = 0.0f;
|
||||||
|
if (settings_ && settings_->getLockFailedAttempts() != 0) {
|
||||||
|
settings_->setLockFailedAttempts(0);
|
||||||
|
settings_->setLockoutUntilEpoch(0);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
|
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||||
|
idle_.noteActivity();
|
||||||
|
state_.encrypted = true;
|
||||||
|
state_.locked = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool App::seedChatDemoData()
|
bool App::seedChatDemoData()
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
||||||
@@ -5625,6 +5746,48 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import a whole wallet-key dump file (the output of the OLD wallet's z_exportwallet / dumpwallet, or
|
||||||
|
// SilentDragon's "export all private keys") via z_importwallet: the daemon reads every key from the
|
||||||
|
// file and does ONE rescan — the right tool for recovering a legacy wallet.dat the new client can't open,
|
||||||
|
// and far faster than importing keys one by one. Only the PATH touches our memory, never the keys. Runs on
|
||||||
|
// the long-call lane (no client timeout: the rescan can take a long time). Full-node only.
|
||||||
|
void App::importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback)
|
||||||
|
{
|
||||||
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
|
if (callback) callback(false, TR("import_key_need_node"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string p(path);
|
||||||
|
while (!p.empty() && (p.front()==' '||p.front()=='\t'||p.front()=='\n'||p.front()=='\r')) p.erase(p.begin());
|
||||||
|
while (!p.empty() && (p.back()==' '||p.back()=='\t'||p.back()=='\n'||p.back()=='\r')) p.pop_back();
|
||||||
|
if (p.empty()) { if (callback) callback(false, TR("import_wallet_file_need_path")); return; }
|
||||||
|
{
|
||||||
|
std::string why; const char* trKey = nullptr;
|
||||||
|
if (longWalletOpRefused(&why, &trKey)) { if (callback) callback(false, TR(trKey)); return; }
|
||||||
|
}
|
||||||
|
const bool started = runLongWalletTask([this, p, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||||
|
std::string err;
|
||||||
|
try {
|
||||||
|
rpc::RPCClient::TraceScope trace("Settings / Import wallet file");
|
||||||
|
c.call("z_importwallet", {p}, 0L); // reads the dump + one rescan; 0 = no client timeout
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
err = e.what();
|
||||||
|
if (!err.empty() && err.find("Cannot open") != std::string::npos)
|
||||||
|
err = TR("import_wallet_file_not_found"); // the most common cause: wrong/missing path
|
||||||
|
} catch (...) {
|
||||||
|
err = "Import failed (unknown error)";
|
||||||
|
}
|
||||||
|
return [this, err, callback]() {
|
||||||
|
if (!err.empty()) { if (callback) callback(false, err); return; }
|
||||||
|
invalidateAddressValidationCache();
|
||||||
|
refreshAddresses();
|
||||||
|
markWalletMutated(/*forceBalance=*/true); // keys added: balance changes with no tip/mempool signal
|
||||||
|
if (callback) callback(true, "");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!started && callback) callback(false, TR("appx_wallet_op_running"));
|
||||||
|
}
|
||||||
|
|
||||||
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||||||
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
||||||
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
||||||
@@ -5635,7 +5798,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
const std::string& destExisting)
|
const std::string& destExisting)
|
||||||
{
|
{
|
||||||
if (!state_.connected || !rpc_ || !worker_) {
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
sweep_status_ = "Not connected to the daemon.";
|
sweep_status_ = TR("err_not_connected_daemon");
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5647,7 +5810,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
||||||
// can't sign, and this must never accidentally route to z_importviewingkey.
|
// can't sign, and this must never accidentally route to z_importviewingkey.
|
||||||
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
||||||
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
|
sweep_status_ = TR("sweep_err_need_spending_key");
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5665,7 +5828,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
sweep_step_ = SweepStep::Running;
|
sweep_step_ = SweepStep::Running;
|
||||||
sweep_status_ = "Importing key & rescanning…";
|
sweep_status_ = TR("sweep_status_importing");
|
||||||
sweep_txid_.clear();
|
sweep_txid_.clear();
|
||||||
sweep_dest_shown_.clear();
|
sweep_dest_shown_.clear();
|
||||||
|
|
||||||
@@ -5726,12 +5889,12 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
||||||
|
|
||||||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||||||
|
// Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as
|
||||||
|
// 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup.
|
||||||
auto balAt = [&](int minconf) -> double {
|
auto balAt = [&](int minconf) -> double {
|
||||||
try {
|
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
return b.is_number() ? b.get<double>()
|
||||||
return b.is_number() ? b.get<double>()
|
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
|
||||||
} catch (...) { return 0.0; }
|
|
||||||
};
|
};
|
||||||
double bal = balAt(1);
|
double bal = balAt(1);
|
||||||
if (bal <= 0.0) {
|
if (bal <= 0.0) {
|
||||||
@@ -5759,7 +5922,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
err = e.what();
|
err = e.what();
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
err = "Sweep failed (unknown error)";
|
err = TR("sweep_err_unknown");
|
||||||
}
|
}
|
||||||
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
|
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
|
||||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||||
@@ -5767,16 +5930,16 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
invalidateAddressValidationCache();
|
invalidateAddressValidationCache();
|
||||||
refreshAddresses();
|
refreshAddresses();
|
||||||
if (!err.empty()) {
|
if (!err.empty()) {
|
||||||
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
|
if (err == "__NOFUNDS__") sweep_status_ = TR("sweep_err_nofunds");
|
||||||
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
|
else if (err == "__UNCONFIRMED__") sweep_status_ = TR("sweep_err_unconfirmed");
|
||||||
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
|
else if (err == "__ALREADY__") sweep_status_ = TR("sweep_err_already");
|
||||||
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
|
else if (err == "__DUST__") sweep_status_ = TR("sweep_err_dust");
|
||||||
else sweep_status_ = err;
|
else sweep_status_ = err;
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sweep_dest_shown_ = dest;
|
sweep_dest_shown_ = dest;
|
||||||
sweep_status_ = "Sweeping funds to your address…";
|
sweep_status_ = TR("sweep_status_sweeping");
|
||||||
// Send all funds from the swept address to the destination through the tested z_sendmany
|
// Send all funds from the swept address to the destination through the tested z_sendmany
|
||||||
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
||||||
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
||||||
@@ -6283,7 +6446,7 @@ void App::showSeedMigrationDialog()
|
|||||||
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
||||||
trackOperation(sweepOpid);
|
trackOperation(sweepOpid);
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Checking on the previous sweep…";
|
seed_migration_status_ = TR("migrate_status_checking_sweep");
|
||||||
break;
|
break;
|
||||||
case MigrationResume::SweepGate:
|
case MigrationResume::SweepGate:
|
||||||
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
||||||
@@ -6361,15 +6524,15 @@ void App::beginSweepToSeedWallet()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rpc_ || !worker_ || !state_.connected) {
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
seed_migration_status_ = "Not connected to the daemon.";
|
seed_migration_status_ = TR("err_not_connected_daemon");
|
||||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||||
}
|
}
|
||||||
if (seed_migration_dest_.empty()) {
|
if (seed_migration_dest_.empty()) {
|
||||||
seed_migration_status_ = "No destination address for the sweep.";
|
seed_migration_status_ = TR("migrate_err_no_dest");
|
||||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||||
}
|
}
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Building the sweep transaction…";
|
seed_migration_status_ = TR("migrate_status_building");
|
||||||
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
||||||
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
||||||
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
||||||
@@ -6385,7 +6548,27 @@ void App::beginSweepToSeedWallet()
|
|||||||
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
||||||
opid = res.value("opid", std::string());
|
opid = res.value("opid", std::string());
|
||||||
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
||||||
} catch (const std::exception& e) { err = e.what(); }
|
} catch (const std::exception& e) {
|
||||||
|
// z_mergetoaddress deliberately ignores coinbase UTXOs, so a miner whose legacy balance is all
|
||||||
|
// mined coinbase gets "Could not find any funds to merge". Fall back to z_shieldcoinbase, which
|
||||||
|
// DOES move coinbase, to the same shielded destination. It returns an opid exactly like the merge,
|
||||||
|
// so the adopt gate (one opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged; a batch
|
||||||
|
// limit or a mixed coinbase+other wallet is drained by the existing "Sweep remaining" re-sweep.
|
||||||
|
const std::string mergeErr = e.what();
|
||||||
|
if (mergeErr.find("find any funds to merge") != std::string::npos) {
|
||||||
|
try {
|
||||||
|
// Fixed-decimal fee string (ParseFixedPoint rejects a double that serializes as "5e-05").
|
||||||
|
const std::string feeStr = util::formatAmountFixed(DRAGONX_DEFAULT_FEE);
|
||||||
|
auto sres = rpc_->call("z_shieldcoinbase", {std::string("*"), dest, feeStr, 50});
|
||||||
|
opid = sres.value("opid", std::string());
|
||||||
|
if (opid.empty()) err = "The daemon returned no operation id for the coinbase shield.";
|
||||||
|
} catch (const std::exception& e2) {
|
||||||
|
err = e2.what(); // e.g. "Could not find any coinbase funds to shield" = genuinely empty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = mergeErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
return [this, opid, err]() {
|
return [this, opid, err]() {
|
||||||
if (!err.empty() || opid.empty()) {
|
if (!err.empty() || opid.empty()) {
|
||||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||||
@@ -6409,7 +6592,7 @@ void App::beginSweepToSeedWallet()
|
|||||||
}
|
}
|
||||||
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
||||||
trackOperation(opid);
|
trackOperation(opid);
|
||||||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
seed_migration_status_ = TR("migrate_status_waiting_accept");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6561,6 +6744,57 @@ void App::locateSweepTxid()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final gate before the swap: on the RPC worker, RE-READ the sweep tx's confirmations (when a sweep txid
|
||||||
|
// exists) and z_gettotalbalance with minconf 0 (includes unconfirmed, unlike the default minconf 1), then
|
||||||
|
// - only if both are reorg-safe, the node is not syncing and the legacy balance is ~0 - call
|
||||||
|
// beginAdoptSeedWallet() on the main thread, so the disconnect + daemon stop follow these reads at once.
|
||||||
|
// Read-only RPCs; any failure blocks adopt (nothing is stopped or swapped) and the user may retry.
|
||||||
|
void App::verifyThenAdoptSeedWallet()
|
||||||
|
{
|
||||||
|
if (capture_mode_) { beginAdoptSeedWallet(); return; } // UI sweep: unchanged behaviour
|
||||||
|
if (seed_migration_confirm_in_flight_) return;
|
||||||
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seed_migration_confirm_in_flight_ = true;
|
||||||
|
const std::string txid = seed_migration_sweep_txid_;
|
||||||
|
const bool needTx = !txid.empty();
|
||||||
|
const bool syncing = effectivelySyncing(); // main-thread snapshot for the worker
|
||||||
|
worker_->post([this, txid, needTx, syncing]() -> rpc::RPCWorker::MainCb {
|
||||||
|
int confs = 0; double total = -1.0; bool ok = false;
|
||||||
|
rpc::RPCClient::TraceScope trace("Migrate / pre-adopt verify");
|
||||||
|
try {
|
||||||
|
if (needTx) {
|
||||||
|
auto tx = rpc_->call("gettransaction", {txid});
|
||||||
|
if (!tx.contains("confirmations") || !tx["confirmations"].is_number()) throw std::runtime_error("no confirmations");
|
||||||
|
confs = tx["confirmations"].get<int>();
|
||||||
|
}
|
||||||
|
auto bal = rpc_->call("z_gettotalbalance", nlohmann::json::array({0})); // minconf 0
|
||||||
|
if (bal.contains("total") && bal["total"].is_string()) {
|
||||||
|
total = std::stod(bal["total"].get<std::string>());
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (...) { ok = false; }
|
||||||
|
return [this, needTx, syncing, confs, total, ok]() {
|
||||||
|
seed_migration_confirm_in_flight_ = false;
|
||||||
|
if (ok) {
|
||||||
|
if (needTx) seed_migration_sweep_confs_ = confs;
|
||||||
|
seed_migration_legacy_remaining_ = total;
|
||||||
|
}
|
||||||
|
// Only act while still on a step that offers Adopt (the user may have closed/changed it).
|
||||||
|
const bool onAdoptStep = seed_migration_step_ == SeedMigrationStep::Confirming ||
|
||||||
|
seed_migration_step_ == SeedMigrationStep::Sweep;
|
||||||
|
if (ok && onAdoptStep &&
|
||||||
|
dragonx::adoptReverifyPasses(needTx, confs, total, syncing, DRAGONX_DEFAULT_FEE)) {
|
||||||
|
beginAdoptSeedWallet(); // unchanged: stop daemon -> swap -> restart with -rescan
|
||||||
|
} else if (onAdoptStep) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"), 12.0f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||||||
@@ -6568,7 +6802,7 @@ void App::beginAdoptSeedWallet()
|
|||||||
{
|
{
|
||||||
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
||||||
seed_migration_step_ = SeedMigrationStep::Adopting;
|
seed_migration_step_ = SeedMigrationStep::Adopting;
|
||||||
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…";
|
seed_migration_status_ = TR("migrate_status_restarting");
|
||||||
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
||||||
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
||||||
daemon_restarting_ = true;
|
daemon_restarting_ = true;
|
||||||
@@ -6622,21 +6856,53 @@ void App::beginAdoptSeedWallet()
|
|||||||
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
||||||
if (!fs::exists(newWallet)) {
|
if (!fs::exists(newWallet)) {
|
||||||
err = "The new wallet file is missing; your wallet was not changed.";
|
err = "The new wallet file is missing; your wallet was not changed.";
|
||||||
|
} else if (!util::probeWalletFile(newWallet).isBerkeleyDB) {
|
||||||
|
err = "The new wallet file is not a valid wallet; your wallet was not changed.";
|
||||||
} else {
|
} else {
|
||||||
bool movedLegacy = false;
|
// Crash-safe order: wallet.dat is only ever REPLACED by one atomic rename of a complete,
|
||||||
if (fs::exists(legacy)) {
|
// size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy
|
||||||
fs::rename(legacy, backup, ec);
|
// wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the
|
||||||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
// whole new wallet - never missing or half-written. Nothing of the user's is deleted.
|
||||||
else movedLegacy = true;
|
const std::string staged = legacy + ".adopt-tmp";
|
||||||
}
|
auto sameSize = [](const std::string& a, const std::string& b) {
|
||||||
if (err.empty()) {
|
std::error_code e1, e2;
|
||||||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
const auto sa = fs::file_size(a, e1);
|
||||||
if (ec) {
|
const auto sb = fs::file_size(b, e2);
|
||||||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
return !e1 && !e2 && sa == sb && sa > 0;
|
||||||
err = "Could not install the new wallet; your original wallet was restored.";
|
};
|
||||||
} else {
|
#ifndef _WIN32
|
||||||
swapDone = true;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6917,9 +7183,21 @@ void App::rebuildWalletDatabase()
|
|||||||
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
||||||
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
||||||
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
||||||
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
// runHiddenCapture runs this via popen("sh -c …") on POSIX, where $( ), backticks and an
|
||||||
|
// embedded \" still expand inside the double-quotes — so a crafted wallet filename (src is
|
||||||
|
// datadir + the activeWalletName setting) could inject a shell command. Refuse any path
|
||||||
|
// carrying a shell metacharacter; rc stays -1 and the verify-before-swap below fails safe.
|
||||||
|
auto shellSafe = [](const std::string& p) {
|
||||||
|
return p.find_first_of("\"'`$;&|<>^\n\r") == std::string::npos;
|
||||||
|
};
|
||||||
int rc = -1;
|
int rc = -1;
|
||||||
const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
|
std::string jout;
|
||||||
|
if (shellSafe(helper) && shellSafe(src) && shellSafe(tmpOut)) {
|
||||||
|
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
||||||
|
jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
|
||||||
|
} else {
|
||||||
|
DEBUG_LOGF("[App] wallet-rebuild refused: shell metacharacter in a path\n");
|
||||||
|
}
|
||||||
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
|
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
|
||||||
|
|
||||||
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
||||||
@@ -7105,7 +7383,14 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
wallet::LiteSendRequest req;
|
wallet::LiteSendRequest req;
|
||||||
wallet::LiteSendRecipient recipient;
|
wallet::LiteSendRecipient recipient;
|
||||||
recipient.address = to;
|
recipient.address = to;
|
||||||
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0));
|
// Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a
|
||||||
|
// sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value.
|
||||||
|
std::uint64_t zat = 0;
|
||||||
|
if (!util::coinsToZatoshis(amount, zat)) {
|
||||||
|
if (callback) callback(false, "Invalid send amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipient.amountZatoshis = zat;
|
||||||
recipient.memo = memo;
|
recipient.memo = memo;
|
||||||
req.recipients.push_back(std::move(recipient));
|
req.recipients.push_back(std::move(recipient));
|
||||||
if (!lite_wallet_->sendTransaction(req)) {
|
if (!lite_wallet_->sendTransaction(req)) {
|
||||||
@@ -7189,6 +7474,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
||||||
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
|
||||||
std::string result_str;
|
std::string result_str;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
@@ -7197,12 +7483,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
ok = true;
|
ok = true;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
result_str = e.what();
|
result_str = e.what();
|
||||||
|
maybeInFlight = rpc::sendReplyMayBeLost(e);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
// Non-std throw must not escape into the worker — the main-thread callback
|
// Non-std throw must not escape into the worker — the main-thread callback
|
||||||
// below would never run, leaving a stuck "Sending…" spinner.
|
// below would never run, leaving a stuck "Sending…" spinner.
|
||||||
result_str = "Send failed (unknown error)";
|
result_str = "Send failed (unknown error)";
|
||||||
}
|
}
|
||||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
return [this, callback, ok, maybeInFlight, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||||
@@ -7225,7 +7512,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!background) send_progress_active_ = false;
|
if (!background) send_progress_active_ = false;
|
||||||
if (callback) callback(false, result_str);
|
if (maybeInFlight && !background) {
|
||||||
|
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
|
||||||
|
// distinct "check History before resending" outcome and re-read the wallet so a
|
||||||
|
// committed send shows up promptly. Definitive rejections stay plain failures below.
|
||||||
|
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
|
||||||
|
result_str.c_str());
|
||||||
|
markWalletMutated(/*forceBalance=*/true);
|
||||||
|
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
|
||||||
|
} else if (callback) {
|
||||||
|
callback(false, result_str);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -7410,7 +7707,7 @@ bool App::runtimeRescan(int startHeight)
|
|||||||
rescan_confirmed_active_ = true;
|
rescan_confirmed_active_ = true;
|
||||||
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
||||||
state_.sync.rescan_progress = 0.0f;
|
state_.sync.rescan_progress = 0.0f;
|
||||||
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
|
{ char rb[64]; snprintf(rb, sizeof(rb), TR("rescan_status_from_block"), startHeight); state_.sync.rescan_status = rb; }
|
||||||
markTransactionsDirty();
|
markTransactionsDirty();
|
||||||
last_tx_block_height_ = -1;
|
last_tx_block_height_ = -1;
|
||||||
invalidateShieldedHistoryScanProgress(true);
|
invalidateShieldedHistoryScanProgress(true);
|
||||||
|
|||||||
+24
-15
@@ -548,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||||
|
if (lite_wallet_) { // lite: backend unlock with the shared attempt-lockout (no RPC/daemon)
|
||||||
|
std::string liteErr;
|
||||||
|
if (!unlockLiteWallet(passphrase, &liteErr)) {
|
||||||
|
lock_error_msg_ = liteErr;
|
||||||
|
lock_error_timer_ = 3.0f;
|
||||||
|
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
@@ -598,6 +607,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
|||||||
void App::lockWallet() {
|
void App::lockWallet() {
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
|
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
|
||||||
|
wipeDisplayedSecrets(); // hide any revealed seed/key immediately, before the async lock round-trip
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
// Use fast-lane worker to avoid blocking behind refreshData.
|
// Use fast-lane worker to avoid blocking behind refreshData.
|
||||||
@@ -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 (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
||||||
if (!state_.isEncrypted() || state_.isLocked()) return;
|
if (!state_.isEncrypted() || state_.isLocked()) return;
|
||||||
|
|
||||||
// Don't auto-lock while mining — mining is a long-running intentional
|
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
|
||||||
// operation and locking the wallet on the daemon side stops solo mining.
|
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
|
||||||
bool miningActive = state_.mining.generate
|
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
|
||||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
if (state_.mining.generate) {
|
||||||
if (miningActive) {
|
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
|
||||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
|
||||||
idle_.noteActivity();
|
idle_.noteActivity();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1547,7 +1556,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!encrypt_status_.empty()) {
|
if (!encrypt_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1558,7 +1567,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
||||||
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
||||||
: (!encMatch ? TR("enc_mismatch") : "");
|
: (!encMatch ? TR("enc_mismatch") : "");
|
||||||
if (*why) ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", why);
|
if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
|
||||||
}
|
}
|
||||||
|
|
||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
@@ -1642,7 +1651,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!enc_dlg_pin_status_.empty()) {
|
if (!enc_dlg_pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", enc_dlg_pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", enc_dlg_pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1760,7 +1769,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!encrypt_status_.empty()) {
|
if (!encrypt_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1841,7 +1850,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!decryptState.status.empty()) {
|
if (!decryptState.status.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str());
|
ImGui::TextColored(ui::material::ErrorVec4(), "%s", decryptState.status.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2089,7 +2098,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||||
// Completed
|
// Completed
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
||||||
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||||
// In progress - animate
|
// In progress - animate
|
||||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||||
@@ -2248,7 +2257,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2378,7 +2387,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2447,7 +2456,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
@@ -108,8 +108,18 @@ bool ChatDatabase::append(const ChatMessage& message)
|
|||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
std::string plain;
|
||||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
bool encrypted = false;
|
||||||
|
try {
|
||||||
|
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
|
||||||
|
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
|
||||||
|
plain = serialize(message);
|
||||||
|
encrypted = encrypt(plain, nonce, cipher);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
|
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
||||||
if (!encrypted) return false;
|
if (!encrypted) return false;
|
||||||
|
|
||||||
@@ -187,8 +197,16 @@ bool ChatDatabase::upsert(const ChatMessage& message)
|
|||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain = serialize(message);
|
std::string plain;
|
||||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
bool encrypted = false;
|
||||||
|
try {
|
||||||
|
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
|
||||||
|
encrypted = encrypt(plain, nonce, cipher);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
|
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
if (!encrypted) return false;
|
if (!encrypted) return false;
|
||||||
|
|
||||||
@@ -416,7 +434,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
|
|||||||
json["ts"] = message.timestamp;
|
json["ts"] = message.timestamp;
|
||||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||||
json["dl"] = static_cast<int>(message.delivery);
|
json["dl"] = static_cast<int>(message.delivery);
|
||||||
return json.dump();
|
return json.dump(); // strict: an invalid-UTF-8 body throws and is DROPPED by the callers (contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "chat_store.h"
|
#include "chat_store.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
|
|
||||||
@@ -112,6 +113,10 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ChatStore::clear() {
|
void ChatStore::clear() {
|
||||||
|
// Zero decrypted plaintext (message bodies + peer address) before dropping them — clear() runs on
|
||||||
|
// wallet lock / switch / teardown, and the freed std::strings would otherwise linger in the heap.
|
||||||
|
auto wipe = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
for (auto& m : messages_) { wipe(m.body); wipe(m.fail_reason); wipe(m.peer_zaddr); }
|
||||||
messages_.clear();
|
messages_.clear();
|
||||||
seen_.clear();
|
seen_.clear();
|
||||||
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
||||||
|
|||||||
@@ -703,8 +703,8 @@ bool EmbeddedDaemon::start(const std::string& binary_path, int lockWaitPolls)
|
|||||||
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||||
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||||
// string that would then be all getLastError()/the UI ever sees.
|
// string that would then be all getLastError()/the UI ever sees.
|
||||||
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
std::string detail = getLastError();
|
||||||
: last_error_;
|
if (detail.empty()) detail = "Failed to start dragonxd process";
|
||||||
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||||
setState(State::Error, detail);
|
setState(State::Error, detail);
|
||||||
return false;
|
return false;
|
||||||
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
char errBuf[256] = {0};
|
char errBuf[256] = {0};
|
||||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||||
last_error_ = "CreateProcess failed with error " + std::to_string(err) + ": " + errBuf;
|
setLastError("CreateProcess failed with error " + std::to_string(err) + ": " + errBuf);
|
||||||
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// Create pipe for stdout/stderr
|
// Create pipe for stdout/stderr
|
||||||
int pipefd[2];
|
int pipefd[2];
|
||||||
if (pipe(pipefd) == -1) {
|
if (pipe(pipefd) == -1) {
|
||||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create pipe: " + std::string(strerror(errno)));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// macOS, which has no pipe2().
|
// macOS, which has no pipe2().
|
||||||
int execpipe[2];
|
int execpipe[2];
|
||||||
if (pipe(execpipe) == -1) {
|
if (pipe(execpipe) == -1) {
|
||||||
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create exec-status pipe: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
return false;
|
return false;
|
||||||
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
setLastError("Fork failed: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
close(execpipe[0]);
|
close(execpipe[0]);
|
||||||
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
int status;
|
int status;
|
||||||
waitpid(pid, &status, 0);
|
waitpid(pid, &status, 0);
|
||||||
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||||
" — not executable or wrong architecture";
|
" — not executable or wrong architecture");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ private:
|
|||||||
std::atomic<bool> external_daemon_detected_{false};
|
std::atomic<bool> external_daemon_detected_{false};
|
||||||
std::string last_error_;
|
std::string last_error_;
|
||||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||||
|
// Locked writer for last_error_ (startProcess() runs on the caller thread while the monitor may call setState()).
|
||||||
|
void setLastError(std::string message) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(message); }
|
||||||
mutable std::mutex output_mutex_; // protects process_output_
|
mutable std::mutex output_mutex_; // protects process_output_
|
||||||
std::string process_output_;
|
std::string process_output_;
|
||||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||||
@@ -511,14 +513,14 @@ private:
|
|||||||
|
|
||||||
// Process handle
|
// Process handle
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE process_handle_ = nullptr;
|
std::atomic<HANDLE> process_handle_{nullptr}; // written by start/stop/monitor threads, read by accessors
|
||||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||||
std::string launch_binary_; // binary path used (for diagnostics)
|
std::string launch_binary_; // binary path used (for diagnostics)
|
||||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||||
#else
|
#else
|
||||||
pid_t process_pid_ = 0;
|
std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
|
||||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||||
void tailDebugLog();
|
void tailDebugLog();
|
||||||
|
|
||||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||||
|
|||||||
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
|||||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||||
|
|
||||||
|
// Reorg-safe depth before the adopt swap is enabled. Matches the History "confirmed" threshold
|
||||||
|
// (>= 10, data/transaction_history_store_policy.h): a 1-block reorg must not be able to un-mine the
|
||||||
|
// sweep after wallet.dat has been moved aside, which would strand the funds in the .bak.
|
||||||
|
inline constexpr int kSweepAdoptMinConfs = 10;
|
||||||
|
inline bool sweepConfsAreReorgSafe(int confs) { return confs >= kSweepAdoptMinConfs; }
|
||||||
|
|
||||||
|
// Final pre-stop re-verification, run on fresh RPC reads immediately before the daemon is stopped.
|
||||||
|
// needSweepTx : a sweep txid exists, so its confirmations must be reorg-safe (false = genuinely
|
||||||
|
// empty wallet, no sweep ever submitted).
|
||||||
|
// confs : fresh gettransaction confirmations (negative = conflicted).
|
||||||
|
// balanceMinconf0 : fresh z_gettotalbalance with minconf 0 (INCLUDES unconfirmed), < 0 = unknown.
|
||||||
|
// syncing : the node is still catching up, so balance reads are not trustworthy.
|
||||||
|
inline bool adoptReverifyPasses(bool needSweepTx, int confs, double balanceMinconf0,
|
||||||
|
bool syncing, double dust)
|
||||||
|
{
|
||||||
|
if (syncing) return false;
|
||||||
|
if (needSweepTx && !sweepConfsAreReorgSafe(confs)) return false;
|
||||||
|
return balanceMinconf0 >= 0.0 && balanceMinconf0 <= dust;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+32
-2
@@ -75,6 +75,13 @@ struct TransactionInfo {
|
|||||||
std::string getTypeDisplay() const;
|
std::string getTypeDisplay() const;
|
||||||
bool isConfirmed() const { return confirmations >= 1; }
|
bool isConfirmed() const { return confirmations >= 1; }
|
||||||
bool isMature() const { return confirmations >= 100; }
|
bool isMature() const { return confirmations >= 100; }
|
||||||
|
// gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out).
|
||||||
|
bool isConflicted() const { return confirmations < 0; }
|
||||||
|
// Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the
|
||||||
|
// mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged).
|
||||||
|
bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const {
|
||||||
|
return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
||||||
@@ -86,6 +93,15 @@ inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo&
|
|||||||
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
a.from_address == b.from_address && a.memo == b.memo;
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
}
|
}
|
||||||
|
// Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the
|
||||||
|
// History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the
|
||||||
|
// tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding.
|
||||||
|
inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) {
|
||||||
|
return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) &&
|
||||||
|
std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 &&
|
||||||
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
|
}
|
||||||
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
||||||
if (a.size() != b.size()) return false;
|
if (a.size() != b.size()) return false;
|
||||||
for (std::size_t i = 0; i < a.size(); ++i)
|
for (std::size_t i = 0; i < a.size(); ++i)
|
||||||
@@ -189,6 +205,11 @@ struct SyncInfo {
|
|||||||
bool tip_known = false;
|
bool tip_known = false;
|
||||||
|
|
||||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||||
|
|
||||||
|
// Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a
|
||||||
|
// peerless node has tip_known == false, so its local height can't be trusted as the network tip.
|
||||||
|
// The lite backend never populates tip_known, so lite is exempt (it has its own sync gate).
|
||||||
|
bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -370,12 +391,21 @@ struct WalletState {
|
|||||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
||||||
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||||
std::uint64_t tx_revision = 1;
|
std::uint64_t tx_revision = 1;
|
||||||
void touchTransactions() { ++tx_revision; }
|
// Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's
|
||||||
|
// O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that
|
||||||
|
// is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default).
|
||||||
|
std::uint64_t tx_structure_revision = 1;
|
||||||
|
void touchTransactions() { ++tx_revision; ++tx_structure_revision; }
|
||||||
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
||||||
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
||||||
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||||
if (sameTransactionList(transactions, next)) return false;
|
if (sameTransactionList(transactions, next)) return false;
|
||||||
replaceTransactions(std::move(next));
|
bool layoutSame = transactions.size() == next.size();
|
||||||
|
for (std::size_t i = 0; layoutSame && i < next.size(); ++i)
|
||||||
|
layoutSame = sameTransactionLayout(transactions[i], next[i]);
|
||||||
|
transactions = std::move(next);
|
||||||
|
++tx_revision;
|
||||||
|
if (!layoutSame) ++tx_structure_revision;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
int64_t last_peer_update = 0;
|
int64_t last_peer_update = 0;
|
||||||
|
|||||||
+9
-18
@@ -460,15 +460,14 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ofstream file(path);
|
// Build the content in memory, then write it 0600 from the start (writeFileAtomically with
|
||||||
if (!file.is_open()) {
|
// restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
|
||||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
// another local user could read the freshly-generated rpcuser/rpcpassword.
|
||||||
return false;
|
std::ostringstream file;
|
||||||
}
|
|
||||||
|
|
||||||
// Get the data directory for exportdir
|
// Get the data directory for exportdir
|
||||||
std::string dataDir = getDefaultDataDir();
|
std::string dataDir = getDefaultDataDir();
|
||||||
|
|
||||||
file << "# DragonX configuration file\n";
|
file << "# DragonX configuration file\n";
|
||||||
file << "# Auto-generated by DragonX Wallet\n";
|
file << "# Auto-generated by DragonX Wallet\n";
|
||||||
file << "\n";
|
file << "\n";
|
||||||
@@ -485,18 +484,10 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
file << "addnode=seed.dragonx.is\n";
|
file << "addnode=seed.dragonx.is\n";
|
||||||
file << "addnode=node1.dragonx.is\n";
|
file << "addnode=node1.dragonx.is\n";
|
||||||
file << "addnode=node5.dragonx.is\n";
|
file << "addnode=node5.dragonx.is\n";
|
||||||
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
|
if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
|
||||||
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
|
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||||
// before another local user can read the credentials.
|
return false;
|
||||||
{
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
std::error_code ec;
|
|
||||||
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
|
|
||||||
fs::perm_options::replace, ec);
|
|
||||||
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
||||||
|
|||||||
+16
-1
@@ -216,7 +216,8 @@ bool RPCClient::open(const std::string& host, const std::string& port,
|
|||||||
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
||||||
clearAbort(); // a fresh connection must not start in the aborted state
|
// NOTE: abort is deliberately NOT cleared here: a requestAbort() issued while this connect was queued
|
||||||
|
// must still cut it short. Reconnect callers (App::tryConnect / onConnected) call clearAbort() first.
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
||||||
@@ -370,6 +371,20 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
|||||||
|
|
||||||
json payload = makePayload(method, params);
|
json payload = makePayload(method, params);
|
||||||
std::string body = payload.dump();
|
std::string body = payload.dump();
|
||||||
|
// Request-side secret scrub (responses are already scrubbed): the body + payload tree can carry
|
||||||
|
// walletpassphrase / encryptwallet / *_importkey args. Wipe them when this scope ends (all return
|
||||||
|
// and throw paths) and detach CURLOPT_POSTFIELDS so the reused handle holds no dangling pointer.
|
||||||
|
struct RequestScrubGuard {
|
||||||
|
CURL* curl; std::string& body; json& payload;
|
||||||
|
~RequestScrubGuard() {
|
||||||
|
if (!body.empty()) sodium_memzero(&body[0], body.size());
|
||||||
|
scrubJsonSecrets(payload);
|
||||||
|
if (curl) {
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, nullptr);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} requestScrubGuard{impl_->curl, body, payload};
|
||||||
std::string response_data;
|
std::string response_data;
|
||||||
|
|
||||||
// Set POST data
|
// Set POST data
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
|||||||
return RpcFailureKind::Other;
|
return RpcFailureKind::Other;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Did a failed z_sendmany (or any submit call) possibly reach the node and take effect? True only for a
|
||||||
|
// transport failure AFTER the request left us with no HTTP status back (reply timeout, connection cut
|
||||||
|
// mid-reply, our own abort): the daemon may already hold the operation, so the caller must not invite an
|
||||||
|
// immediate resend. Definitive answers/rejections (AppError, QueueFull, Auth, Warmup, Deferred,
|
||||||
|
// NotListening, ConnectTimeout, any HTTP status) mean nothing was queued and stay plain failures.
|
||||||
|
inline bool sendReplyMayBeLost(const std::exception& e)
|
||||||
|
{
|
||||||
|
const auto* t = dynamic_cast<const RpcTransportError*>(&e);
|
||||||
|
return t && t->requestSent && t->httpStatus == 0 && t->curlCode != CURLE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lite broadcast errors are plain backend text (tonic/gRPC). Ambiguous = a transport-style failure while
|
||||||
|
// talking to the lightwalletd server (the server may have accepted the tx before the reply was lost).
|
||||||
|
// Connect-phase failures and an explicit server rejection ("error_code") are definitive. Heuristic by
|
||||||
|
// design: a false positive only shows a more cautious message, never changes the send.
|
||||||
|
inline bool liteSendErrorMayBeInFlight(const std::string& err)
|
||||||
|
{
|
||||||
|
std::string lc = err;
|
||||||
|
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
auto has = [&](const char* n) { return lc.find(n) != std::string::npos; };
|
||||||
|
if (has("error_code") || has("error trying to connect") || has("connection refused") ||
|
||||||
|
has("dns error") || has("insufficient") || has("locked"))
|
||||||
|
return false;
|
||||||
|
return has("timed out") || has("timeout") || has("deadline") || has("transport error") ||
|
||||||
|
has("connection reset") || has("broken pipe") || has("h2 protocol error") ||
|
||||||
|
has("unexpected eof") || has("status: unknown") || has("status: cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag carried in the failure text of a send callback (bool, string) so the Send tab can show the distinct
|
||||||
|
// "may have been broadcast - check History before resending" outcome without changing the callback type.
|
||||||
|
// The prefix is readable English so callers that just print the text still say something accurate.
|
||||||
|
inline const char* sendMaybeInFlightPrefix()
|
||||||
|
{
|
||||||
|
return "Send status unknown - it may already have been broadcast: ";
|
||||||
|
}
|
||||||
|
inline bool isSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return s.rfind(sendMaybeInFlightPrefix(), 0) == 0;
|
||||||
|
}
|
||||||
|
inline std::string markSendMaybeInFlight(const std::string& detail)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(detail) ? detail : std::string(sendMaybeInFlightPrefix()) + detail;
|
||||||
|
}
|
||||||
|
inline std::string stripSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(s) ? s.substr(std::string(sendMaybeInFlightPrefix()).size()) : s;
|
||||||
|
}
|
||||||
|
|
||||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
||||||
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
||||||
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "rpc/rpc_busy_gate.h"
|
#include "rpc/rpc_busy_gate.h"
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
||||||
|
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -34,7 +35,20 @@ std::optional<T> readOptional(const json& source, const char* key)
|
|||||||
std::optional<double> readBalanceString(const json& source, const char* key)
|
std::optional<double> readBalanceString(const json& source, const char* key)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (source.contains(key)) return std::stod(source[key].get<std::string>());
|
if (source.contains(key)) {
|
||||||
|
const std::string text = source[key].get<std::string>();
|
||||||
|
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
|
||||||
|
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
|
||||||
|
if (text.empty()) return std::nullopt;
|
||||||
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||||
|
const char c = text[i];
|
||||||
|
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
const double value = std::strtod(text.c_str(), &end);
|
||||||
|
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
@@ -52,15 +66,17 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||||
std::map<std::string, double> total;
|
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
|
||||||
std::map<std::string, double> spendable;
|
std::map<std::string, std::int64_t> total;
|
||||||
|
std::map<std::string, std::int64_t> spendable;
|
||||||
for (const auto& output : unspent) {
|
for (const auto& output : unspent) {
|
||||||
auto address = readOptional<std::string>(output, "address");
|
auto address = readOptional<std::string>(output, "address");
|
||||||
auto amount = readOptional<double>(output, "amount");
|
auto amount = readOptional<double>(output, "amount");
|
||||||
if (!address || !amount) continue;
|
if (!address || !amount || !std::isfinite(*amount)) continue;
|
||||||
total[*address] += *amount;
|
const std::int64_t sats = util::toSats(*amount);
|
||||||
|
total[*address] += sats;
|
||||||
auto conf = readOptional<int>(output, "confirmations");
|
auto conf = readOptional<int>(output, "confirmations");
|
||||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
if (conf && *conf >= 1) spendable[*address] += sats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||||
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
for (auto& info : addresses) {
|
for (auto& info : addresses) {
|
||||||
auto t = total.find(info.address);
|
auto t = total.find(info.address);
|
||||||
auto s = spendable.find(info.address);
|
auto s = spendable.find(info.address);
|
||||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
|
||||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
|
|||||||
|
|
||||||
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry)
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight)
|
||||||
{
|
{
|
||||||
|
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
|
||||||
|
int entryConfirmations = entry.confirmations;
|
||||||
|
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
|
||||||
|
entryConfirmations += currentHeight - entry.observed_height;
|
||||||
for (const auto& out : entry.outgoing_outputs) {
|
for (const auto& out : entry.outgoing_outputs) {
|
||||||
bool alreadyTracked = false;
|
bool alreadyTracked = false;
|
||||||
for (const auto& existing : transactions) {
|
for (const auto& existing : transactions) {
|
||||||
@@ -1073,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!foundExistingMetadata) {
|
if (!foundExistingMetadata) {
|
||||||
info.confirmations = entry.confirmations;
|
info.confirmations = entryConfirmations;
|
||||||
info.timestamp = entry.timestamp;
|
info.timestamp = entry.timestamp;
|
||||||
}
|
}
|
||||||
transactions.push_back(std::move(info));
|
transactions.push_back(std::move(info));
|
||||||
@@ -1460,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
auto cached = snapshot.viewTxCache.find(txid);
|
auto cached = snapshot.viewTxCache.find(txid);
|
||||||
if (cached != snapshot.viewTxCache.end()) {
|
if (cached != snapshot.viewTxCache.end()) {
|
||||||
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
||||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
|
||||||
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
||||||
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
||||||
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
||||||
@@ -1493,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||||
|
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
|
||||||
const bool storedRow = rowBacklog.count(txid) > 0;
|
const bool storedRow = rowBacklog.count(txid) > 0;
|
||||||
if (storedRow && entry.timestamp == 0) {
|
if (storedRow && entry.timestamp == 0) {
|
||||||
const auto settled = settledStoredMeta.find(txid);
|
const auto settled = settledStoredMeta.find(txid);
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ public:
|
|||||||
std::string from_address;
|
std::string from_address;
|
||||||
std::int64_t timestamp = 0;
|
std::int64_t timestamp = 0;
|
||||||
int confirmations = 0;
|
int confirmations = 0;
|
||||||
|
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
|
||||||
struct Output {
|
struct Output {
|
||||||
std::string address;
|
std::string address;
|
||||||
double value = 0.0;
|
double value = 0.0;
|
||||||
@@ -427,9 +428,12 @@ public:
|
|||||||
const std::set<std::string>& miningAddresses = {},
|
const std::set<std::string>& miningAddresses = {},
|
||||||
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
||||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||||
|
// currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports
|
||||||
|
// confirmations advanced by the blocks added since it was observed (display only).
|
||||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry);
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight = -1);
|
||||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_ = openedDb;
|
db_ = openedDb;
|
||||||
|
++revision_;
|
||||||
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
||||||
// freezing the frame for 2 s.
|
// freezing the frame for 2 s.
|
||||||
sqlite3_busy_timeout(db_, 250);
|
sqlite3_busy_timeout(db_, 250);
|
||||||
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::map<int, ExplorerBlockSummary>& ExplorerBlockCache::loadRangeCached(int minHeight, int maxHeight)
|
||||||
|
{
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
if (range_cache_.size() >= 16) range_cache_.clear();
|
||||||
|
const std::pair<int, int> key(minHeight, maxHeight);
|
||||||
|
auto it = range_cache_.find(key);
|
||||||
|
if (it == range_cache_.end()) {
|
||||||
|
auto loaded = loadRange(minHeight, maxHeight); // may open the DB (bumps revision_)
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
it = range_cache_.emplace(key, std::move(loaded)).first;
|
||||||
|
}
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||||
{
|
{
|
||||||
std::vector<ExplorerBlockSummary> results;
|
std::vector<ExplorerBlockSummary> results;
|
||||||
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
|
|||||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||||
{
|
{
|
||||||
if (block.height < 1 || block.hash.empty()) return false;
|
if (block.height < 1 || block.hash.empty()) return false;
|
||||||
|
++revision_;
|
||||||
const bool stored = storeBlockRow(block);
|
const bool stored = storeBlockRow(block);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
spill_.erase(block.height);
|
spill_.erase(block.height);
|
||||||
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
|
|||||||
|
|
||||||
void ExplorerBlockCache::clearBlocks()
|
void ExplorerBlockCache::clearBlocks()
|
||||||
{
|
{
|
||||||
|
++revision_;
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
if (!ensureOpen()) return;
|
if (!ensureOpen()) return;
|
||||||
exec("DELETE FROM explorer_blocks");
|
exec("DELETE FROM explorer_blocks");
|
||||||
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
|
|||||||
void ExplorerBlockCache::pruneTo(int maxRows)
|
void ExplorerBlockCache::pruneTo(int maxRows)
|
||||||
{
|
{
|
||||||
if (maxRows < 1 || !db_) return;
|
if (maxRows < 1 || !db_) return;
|
||||||
|
++revision_;
|
||||||
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
||||||
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
||||||
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
||||||
@@ -333,8 +351,10 @@ ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
|||||||
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
||||||
if (!spill_.empty() && !currentBestHash.empty() &&
|
if (!spill_.empty() && !currentBestHash.empty() &&
|
||||||
(currentHeight < spill_tip_height_ ||
|
(currentHeight < spill_tip_height_ ||
|
||||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
|
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
|
++revision_;
|
||||||
|
}
|
||||||
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
||||||
return validation;
|
return validation;
|
||||||
}
|
}
|
||||||
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
|
|||||||
{
|
{
|
||||||
tip_known_ = false;
|
tip_known_ = false;
|
||||||
if (!db_) return;
|
if (!db_) return;
|
||||||
|
++revision_;
|
||||||
sqlite3_close(db_);
|
sqlite3_close(db_);
|
||||||
db_ = nullptr;
|
db_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct sqlite3;
|
struct sqlite3;
|
||||||
@@ -44,6 +45,11 @@ public:
|
|||||||
const std::string& databasePath() const { return database_path_; }
|
const std::string& databasePath() const { return database_path_; }
|
||||||
|
|
||||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||||
|
// loadRange memoized per (min,max) and invalidated by revision() so the per-frame Explorer list only hits SQLite
|
||||||
|
// after the cache actually changed (store / clear / prune / spill reset / open / close). The reference is valid
|
||||||
|
// until the next call; copy it if you call again before using it.
|
||||||
|
const std::map<int, ExplorerBlockSummary>& loadRangeCached(int minHeight, int maxHeight);
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
||||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||||
@@ -102,6 +108,11 @@ private:
|
|||||||
int open_attempts_ = 0;
|
int open_attempts_ = 0;
|
||||||
int stores_since_prune_ = 0;
|
int stores_since_prune_ = 0;
|
||||||
|
|
||||||
|
// Bumped by every mutation that can change what loadRange returns; keys the loadRangeCached memo.
|
||||||
|
std::uint64_t revision_ = 1;
|
||||||
|
std::uint64_t range_cache_revision_ = 0;
|
||||||
|
std::map<std::pair<int, int>, std::map<int, ExplorerBlockSummary>> range_cache_;
|
||||||
|
|
||||||
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
||||||
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
||||||
static constexpr std::size_t kMaxSpillBlocks = 512;
|
static constexpr std::size_t kMaxSpillBlocks = 512;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Best-effort scrub of ImGui's internal InputText buffers, which retain un-zeroed copies of typed
|
||||||
|
// secrets (wallet passphrase / PIN / seed / private key) after a field is edited — the app's own
|
||||||
|
// char[] buffers are memzeroed, but ImGui also copies the text into its global edit/undo/deactivated
|
||||||
|
// state, which it only frees (not zeroes).
|
||||||
|
//
|
||||||
|
// This reaches into vendored imgui_internal state, so it is PINNED to the vendored ImGui version: a
|
||||||
|
// bump trips the #error below and forces a re-audit of the member names. It is BEST-EFFORT only —
|
||||||
|
// std::-style buffer reallocation, the system clipboard, and OS/IME layers can still hold copies, so
|
||||||
|
// this shrinks the exposure window but does not guarantee absence of secret material in a core dump.
|
||||||
|
|
||||||
|
#include "imgui.h"
|
||||||
|
#include "imgui_internal.h"
|
||||||
|
#include <sodium.h>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#if IMGUI_VERSION_NUM != 19259
|
||||||
|
#error "imgui_secret_scrub.h is pinned to ImGui 1.92.6 (IMGUI_VERSION_NUM 19259). After bumping libs/imgui, re-audit ImGuiInputTextState / ImGuiInputTextDeactivatedState / ImGuiContext member names below, then update this guard."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace ui {
|
||||||
|
|
||||||
|
// Zero ImGui's retained InputText buffers. Safe to call at a lock transition / when a secret dialog
|
||||||
|
// closes (it defocuses the active field). Do NOT call while a secret field is actively being typed.
|
||||||
|
inline void scrubImGuiInputTextState() {
|
||||||
|
ImGuiContext* ctx = ImGui::GetCurrentContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
ImGuiInputTextState& st = ctx->InputTextState;
|
||||||
|
// Defocus first so the next frame can't re-read the buffer we're about to zero (ClearActiveID also
|
||||||
|
// refreshes the deactivated-state copy, which we then zero below).
|
||||||
|
if (st.ID != 0 && ctx->ActiveId == st.ID) ImGui::ClearActiveID();
|
||||||
|
auto zeroVec = [](ImVector<char>& v) {
|
||||||
|
if (v.Data && v.Size > 0) sodium_memzero(v.Data, static_cast<size_t>(v.Size));
|
||||||
|
};
|
||||||
|
zeroVec(st.TextA); // the live edit buffer
|
||||||
|
zeroVec(st.TextToRevertTo); // Escape-to-revert backup
|
||||||
|
zeroVec(st.CallbackTextBackup); // callback reconcile scratch
|
||||||
|
st.TextLen = 0;
|
||||||
|
// NOTE: st.Stb (the stb_textedit undo/redo char buffers) is deliberately NOT zeroed here — its type
|
||||||
|
// ImStbTexteditState is incomplete in imgui_internal.h (fully defined only inside ImGui's .cpp), so we
|
||||||
|
// can't sizeof it from a header. The primary copies above cover the committed text; the undo scratch
|
||||||
|
// is a residual gap in this best-effort scrub.
|
||||||
|
zeroVec(ctx->InputTextDeactivatedState.TextA); // the just-deactivated copy
|
||||||
|
ctx->InputTextDeactivatedState.ID = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui
|
||||||
|
} // namespace dragonx
|
||||||
@@ -23,6 +23,7 @@ enum class NodeBannerAction {
|
|||||||
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||||
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||||
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
||||||
|
RecoverLegacyFunds, // a degraded legacy wallet with stuck transparent funds: open migrate-to-seed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||||
@@ -35,6 +36,8 @@ enum class NodeBannerReason {
|
|||||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||||
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
||||||
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
||||||
|
LegacyFundsStuck, // a DEGRADED legacy wallet (can't derive a z-address) holding transparent funds it
|
||||||
|
// therefore can't shield/spend — offer the migrate-to-seed recovery
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NodeBannerState {
|
struct NodeBannerState {
|
||||||
@@ -75,6 +78,11 @@ struct NodeBannerInputs {
|
|||||||
|
|
||||||
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
||||||
|
|
||||||
|
// The wallet opened DEGRADED (no usable HD seed, so z_getnewaddress / shielding fail) AND it holds
|
||||||
|
// transparent funds that are therefore stuck. App sets this ONLY from wallet_degraded_ + a transparent
|
||||||
|
// balance — never from merely-non-mnemonic status, which is a functional wallet and must not be flagged.
|
||||||
|
bool legacy_funds_stuck = false;
|
||||||
|
|
||||||
std::string connection_status; // human-readable status line (already translated)
|
std::string connection_status; // human-readable status line (already translated)
|
||||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||||
@@ -134,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
? NodeBannerAction::RestartNode
|
? NodeBannerAction::RestartNode
|
||||||
: NodeBannerAction::None;
|
: NodeBannerAction::None;
|
||||||
s.detail.clear();
|
s.detail.clear();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Connected and synced, but a degraded legacy wallet can't shield its transparent funds. Offer the
|
||||||
|
// recovery flow. Checked after NoPeers (can't recover until the node can see the chain anyway).
|
||||||
|
if (in.legacy_funds_stuck) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::LegacyFundsStuck;
|
||||||
|
s.action = NodeBannerAction::RecoverLegacyFunds;
|
||||||
|
s.detail.clear();
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,7 +869,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (skin.bundled) continue;
|
if (skin.bundled) continue;
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
||||||
if (!skin.valid) {
|
if (!skin.valid) {
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::ErrorVec4());
|
||||||
ImGui::BeginDisabled(true);
|
ImGui::BeginDisabled(true);
|
||||||
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
||||||
ImGui::Selectable(lbl.c_str(), false);
|
ImGui::Selectable(lbl.c_str(), false);
|
||||||
@@ -1490,6 +1490,9 @@ void RenderSettingsPage(App* app) {
|
|||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
||||||
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
if (cardBtn(cx, cy, cw, "##imp_file", TR("settings_import_wallet_file"), ICON_MD_UPLOAD_FILE)) app->showImportWalletFileDialog();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_wallet_file"));
|
||||||
last = cy + bh;
|
last = cy + bh;
|
||||||
if (fullNode) {
|
if (fullNode) {
|
||||||
cy += bh + bgp;
|
cy += bh + bgp;
|
||||||
@@ -1649,20 +1652,20 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SameLine(0, Layout::spacingMd());
|
ImGui::SameLine(0, Layout::spacingMd());
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK);
|
ImGui::TextColored(material::WarningVec4(), ICON_MD_LOCK);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("settings_locked"));
|
||||||
} else {
|
} else {
|
||||||
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||||
app->lockWallet();
|
app->lockWallet();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
||||||
ImGui::SameLine(0, Layout::spacingSm());
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
|
ImGui::TextColored(material::SuccessVec4(), ICON_MD_LOCK_OPEN);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_unlocked"));
|
||||||
}
|
}
|
||||||
// Remove Encryption button — trails the row, or wraps below
|
// Remove Encryption button — trails the row, or wraps below
|
||||||
// when the column is too narrow for three buttons + status.
|
// when the column is too narrow for three buttons + status.
|
||||||
@@ -1708,7 +1711,11 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
||||||
bool isEncryptedPIN = app->state().isEncrypted();
|
bool isEncryptedPIN = app->state().isEncrypted();
|
||||||
if (isEncryptedPIN) {
|
// Lite has no PIN quick-unlock: its unlock paths (Settings, send-time prompt) take the passphrase
|
||||||
|
// directly and never consult a PIN vault, so these controls would be dead. Hide them.
|
||||||
|
if (app->isLiteBuild()) {
|
||||||
|
// nothing to show
|
||||||
|
} else if (isEncryptedPIN) {
|
||||||
bool hasPIN = app->hasPinVault();
|
bool hasPIN = app->hasPinVault();
|
||||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
||||||
|
|
||||||
@@ -1756,10 +1763,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
|
ImGui::TextColored(material::SuccessVec4(), ICON_MD_DIALPAD);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_pin_active"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2172,9 +2179,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
||||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = app->unlockLiteWallet(std::string(s_settingsState.lite_enc_pass), &unlockErr);
|
||||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||||
s_settingsState.lite_encryption_status = ok ? TR("lite_wallet_unlocked") : TR("lite_unlock_failed");
|
s_settingsState.lite_encryption_status = ok ? std::string(TR("lite_wallet_unlocked")) : unlockErr;
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
||||||
} else {
|
} else {
|
||||||
@@ -2331,8 +2339,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
||||||
cs.tooltip = TR("copy");
|
cs.tooltip = TR("copy");
|
||||||
const float ih = ImGui::GetFrameHeight();
|
const float ih = ImGui::GetFrameHeight();
|
||||||
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs))
|
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs)) {
|
||||||
ImGui::SetClipboardText(dirPath.c_str());
|
ImGui::SetClipboardText(dirPath.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
||||||
@@ -2987,7 +2997,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
||||||
saveSettingsPageState(app->settings());
|
saveSettingsPageState(app->settings());
|
||||||
if (s_settingsState.stratum_allowip[0] != '\0')
|
if (s_settingsState.stratum_allowip[0] != '\0')
|
||||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.25f, 1.0f), " %s",
|
ImGui::TextColored(material::WarningVec4(), " %s",
|
||||||
TR("stratum_expose_warn"));
|
TR("stratum_expose_warn"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3323,12 +3333,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirmation dialog for deleting blockchain data
|
// Confirmation dialog for deleting blockchain data
|
||||||
if (s_settingsState.confirm_delete_blockchain) {
|
if (s_settingsState.confirm_delete_blockchain) {
|
||||||
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::ErrorVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_delete_blockchain_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_delete_blockchain_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3370,7 +3380,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
@@ -3389,14 +3399,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
} else {
|
} else {
|
||||||
if (s_settingsState.rescan_probe_failed) {
|
if (s_settingsState.rescan_probe_failed) {
|
||||||
// A long line: TextColored does not wrap, so colour a wrapped one.
|
// A long line: TextColored does not wrap, so colour a wrapped one.
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::WarningVec4());
|
||||||
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_rescan_safe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3425,12 +3435,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
||||||
if (s_settingsState.confirm_repair_wallet) {
|
if (s_settingsState.confirm_repair_wallet) {
|
||||||
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_repair_wallet_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3453,12 +3463,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
||||||
if (s_settingsState.confirm_reinstall_daemon) {
|
if (s_settingsState.confirm_reinstall_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_reinstall_daemon_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3482,7 +3492,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
||||||
if (s_settingsState.confirm_restart_daemon) {
|
if (s_settingsState.confirm_restart_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
||||||
@@ -3509,12 +3519,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
||||||
if (s_settingsState.confirm_lite_redownload) {
|
if (s_settingsState.confirm_lite_redownload) {
|
||||||
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_lite_redownload_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_lite_redownload_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
#ifdef DRAGONX_DEBUG
|
#ifdef DRAGONX_DEBUG
|
||||||
ImGui::Text("%s", TR("about_build_type"));
|
ImGui::Text("%s", TR("about_build_type"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("about_debug"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("about_debug"));
|
||||||
#else
|
#else
|
||||||
ImGui::Text("%s", TR("about_build_type"));
|
ImGui::Text("%s", TR("about_build_type"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
@@ -92,7 +92,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
|
|
||||||
ImGui::Text("%s", TR("about_daemon"));
|
ImGui::Text("%s", TR("about_daemon"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("connected"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("connected"));
|
||||||
|
|
||||||
const auto& state = app->getWalletState();
|
const auto& state = app->getWalletState();
|
||||||
ImGui::Text("%s", TR("about_chain"));
|
ImGui::Text("%s", TR("about_chain"));
|
||||||
|
|||||||
@@ -1738,7 +1738,10 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ImGui::BeginPopup("##msgmenu")) {
|
if (ImGui::BeginPopup("##msgmenu")) {
|
||||||
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
|
if (ImGui::MenuItem(TR("copy"))) {
|
||||||
|
ImGui::SetClipboardText(m.body.c_str());
|
||||||
|
Notifications::instance().success(TR("copied"));
|
||||||
|
}
|
||||||
ImGui::EndPopup();
|
ImGui::EndPopup();
|
||||||
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
||||||
s_msgmenu_index = -1; // popup closed
|
s_msgmenu_index = -1; // popup closed
|
||||||
@@ -2481,11 +2484,16 @@ void ResetChatTab()
|
|||||||
s_show_blocked = false;
|
s_show_blocked = false;
|
||||||
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
||||||
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
||||||
|
// Zero the decrypted plaintext the memo caches hold (conversation previews + full thread bodies)
|
||||||
|
// before dropping them, so the previous wallet's messages don't linger in freed heap.
|
||||||
|
auto wipeStr2 = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
for (auto& c : s_convs) wipeStr2(c.lastBody);
|
||||||
s_convs.clear();
|
s_convs.clear();
|
||||||
s_convsHidden = 0;
|
s_convsHidden = 0;
|
||||||
s_convsKey = ~0ull;
|
s_convsKey = ~0ull;
|
||||||
s_threadCid.clear();
|
s_threadCid.clear();
|
||||||
s_threadRev = ~0ull;
|
s_threadRev = ~0ull;
|
||||||
|
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
|
||||||
s_threadMsgs.clear();
|
s_threadMsgs.clear();
|
||||||
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
|
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
|
||||||
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
|
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// tab completion, daemon log display, and color-coded output.
|
// tab completion, daemon log display, and color-coded output.
|
||||||
|
|
||||||
#include "console_tab.h"
|
#include "console_tab.h"
|
||||||
|
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
|
||||||
#include "console_command_executor.h"
|
#include "console_command_executor.h"
|
||||||
#include "console_command_reference.h"
|
#include "console_command_reference.h"
|
||||||
#include "console_input_model.h"
|
#include "console_input_model.h"
|
||||||
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
|||||||
{
|
{
|
||||||
std::string result;
|
std::string result;
|
||||||
bool isError = false;
|
bool isError = false;
|
||||||
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
|
while (exec.pollResult(result, isError)) {
|
||||||
|
const bool secret = secret_result_armed_ && !isError;
|
||||||
|
secret_result_armed_ = false; // the first (non-error) result consumes the arming
|
||||||
|
if (secret) {
|
||||||
|
// Hold the plaintext ONLY in secret_result_ and show a masked placeholder — it never
|
||||||
|
// enters the scrollback model, so Copy All / Ctrl+C / the filter can't leak it.
|
||||||
|
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||||
|
secret_result_ = result;
|
||||||
|
secret_result_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(60);
|
||||||
|
addLine(TR("console_secret_result_hidden"), ConsoleChannel::Warning);
|
||||||
|
} else {
|
||||||
|
addFormattedResult(result, isError);
|
||||||
|
}
|
||||||
|
if (!result.empty()) sodium_memzero(&result[0], result.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-wipe a revealed secret result after its deadline so it doesn't linger for the session.
|
||||||
|
if (!secret_result_.empty() && std::chrono::steady_clock::now() >= secret_result_deadline_) {
|
||||||
|
wipeSecretResult();
|
||||||
|
addLine(TR("console_secret_result_expired"), ConsoleChannel::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
||||||
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
|||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a key-revealing result is masked, offer an explicit Copy-secret (through the auto-clearing
|
||||||
|
// clipboard) and a Hide action. The secret is never in the model, so the normal Copy above is safe.
|
||||||
|
if (hasSecretResult()) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("console_copy_secret"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
|
||||||
|
if (copy_secret_cb_) copy_secret_cb_(secret_result_);
|
||||||
|
else ImGui::SetClipboardText(secret_result_.c_str());
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_copy_secret_tip"));
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("console_hide_secret"), ImVec2(0, 0), schema::UI().resolveFont("button")))
|
||||||
|
wipeSecretResult();
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
|
||||||
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
||||||
@@ -1565,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
|||||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||||
{
|
{
|
||||||
if (cmd.empty()) return false;
|
if (cmd.empty()) return false;
|
||||||
|
secret_result_armed_ = false; // cleared each submit so a no-result command can't mask the next one
|
||||||
|
|
||||||
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||||
// log and the recall history. The real `cmd` below is still executed unredacted.
|
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||||
@@ -1622,6 +1657,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
|
|||||||
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
||||||
} else {
|
} else {
|
||||||
destructive_confirm_cmd_.clear();
|
destructive_confirm_cmd_.clear();
|
||||||
|
// Arm result-masking for key-revealing commands so their OUTPUT is hidden in the scrollback.
|
||||||
|
secret_result_armed_ = ConsoleCommandRevealsSecret(cmd, exec.hasRpcReference());
|
||||||
exec.submit(cmd);
|
exec.submit(cmd);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -2202,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
|
|||||||
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConsoleTab::wipeSecretResult()
|
||||||
|
{
|
||||||
|
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||||
|
secret_result_.clear();
|
||||||
|
secret_result_armed_ = false;
|
||||||
|
secret_result_deadline_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
void ConsoleTab::clear()
|
void ConsoleTab::clear()
|
||||||
{
|
{
|
||||||
|
wipeSecretResult(); // the clear command / toolbar button also drops any held secret result
|
||||||
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
||||||
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||||
model_.clear();
|
model_.clear();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <functional>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -66,6 +68,13 @@ public:
|
|||||||
*/
|
*/
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
|
// Masked secret-result support: the plaintext of a key-revealing command's output is held ONLY here
|
||||||
|
// (never in the scrollback model), so Copy All / Ctrl+C / the filter can't leak it. An explicit
|
||||||
|
// "Copy secret" action routes through this handler (the app wires it to the auto-clearing clipboard).
|
||||||
|
void setCopySecretHandler(std::function<void(const std::string&)> f) { copy_secret_cb_ = std::move(f); }
|
||||||
|
bool hasSecretResult() const { return !secret_result_.empty(); }
|
||||||
|
void wipeSecretResult(); // zero + drop the buffer, disarm, reset the deadline (call on lock/clear)
|
||||||
|
|
||||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||||
void requestInputFocus() { focus_input_pending_ = true; }
|
void requestInputFocus() { focus_input_pending_ = true; }
|
||||||
@@ -162,6 +171,10 @@ private:
|
|||||||
char input_buffer_[4096] = {0};
|
char input_buffer_[4096] = {0};
|
||||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||||
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
||||||
|
bool secret_result_armed_ = false; // the just-submitted command reveals key material in its result
|
||||||
|
std::string secret_result_; // plaintext of the masked result (member-only; never in model_)
|
||||||
|
std::chrono::steady_clock::time_point secret_result_deadline_{}; // auto-wipe the buffer at this time
|
||||||
|
std::function<void(const std::string&)> copy_secret_cb_; // routes "Copy secret" to the app
|
||||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const char* const kSecretConsoleCommands[] = {
|
|||||||
"importprivkey", "importwallet", "importmulti",
|
"importprivkey", "importwallet", "importmulti",
|
||||||
"z_importkey", "z_importviewingkey", "z_importwallet",
|
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||||
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
||||||
|
// Lite-wallet verbs (console_command_reference kLiteKeyCommands): these take a spending/viewing key
|
||||||
|
// or a password as their argument. Full-node has no bare import/encrypt/decrypt/unlock verb, so a
|
||||||
|
// flat match is safe.
|
||||||
|
"import", "timport", "encrypt", "decrypt", "unlock",
|
||||||
};
|
};
|
||||||
|
|
||||||
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
||||||
@@ -67,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode)
|
||||||
|
{
|
||||||
|
size_t end = 0;
|
||||||
|
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||||
|
if (name.empty()) return false;
|
||||||
|
if (fullNode) {
|
||||||
|
static const char* const kFull[] = {
|
||||||
|
"dumpprivkey", "z_exportkey", "z_exportviewingkey",
|
||||||
|
"z_exportmnemonic", "dumpwallet", "z_exportwallet",
|
||||||
|
};
|
||||||
|
for (const char* s : kFull) if (name == s) return true;
|
||||||
|
} else {
|
||||||
|
// Lite backend verbs whose output is the seed phrase / private keys.
|
||||||
|
static const char* const kLite[] = { "seed", "export" };
|
||||||
|
for (const char* s : kLite) if (name == s) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::string RedactConsoleCommand(const std::string& cmd)
|
std::string RedactConsoleCommand(const std::string& cmd)
|
||||||
{
|
{
|
||||||
size_t end = 0;
|
size_t end = 0;
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ bool ShouldPrintDaemonErrorLine(bool wasError, bool isError,
|
|||||||
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||||
|
|
||||||
|
// True when the command's RESULT (not its args) reveals key material — dumpprivkey / z_exportkey /
|
||||||
|
// z_exportviewingkey / z_exportmnemonic / dumpwallet / z_exportwallet on the full node, or the lite
|
||||||
|
// `seed` / `export` verbs. Used to mask the output in the console scrollback. `fullNode` selects the
|
||||||
|
// command set (the bare lite verbs `seed`/`export` must not match full-node commands).
|
||||||
|
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode);
|
||||||
|
|
||||||
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||||
std::string RedactConsoleCommand(const std::string& cmd);
|
std::string RedactConsoleCommand(const std::string& cmd);
|
||||||
|
|||||||
@@ -846,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
|
|
||||||
std::map<int, BlockSummary> cachedBlocks;
|
std::map<int, BlockSummary> cachedBlocks;
|
||||||
if (!pageHeights.empty()) {
|
if (!pageHeights.empty()) {
|
||||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
// Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame.
|
||||||
|
cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
||||||
@@ -890,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
}
|
}
|
||||||
std::map<int, BlockSummary> nextCachedBlocks;
|
std::map<int, BlockSummary> nextCachedBlocks;
|
||||||
if (!nextPageHeights.empty()) {
|
if (!nextPageHeights.empty()) {
|
||||||
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
|
nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front());
|
||||||
}
|
}
|
||||||
for (int height : nextPageHeights) {
|
for (int height : nextPageHeights) {
|
||||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "../../rpc/rpc_worker.h"
|
#include "../../rpc/rpc_worker.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/export_filename.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -117,7 +118,7 @@ void ExportAllKeysDialog::render(App* app)
|
|||||||
} else if (!s_status.empty()) {
|
} else if (!s_status.empty()) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", s_status.c_str());
|
ImGui::TextColored(material::ErrorVec4(), "%s", s_status.c_str());
|
||||||
ImGui::PopTextWrapPos();
|
ImGui::PopTextWrapPos();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +161,11 @@ void ExportAllKeysDialog::render(App* app)
|
|||||||
if (s_include_t) {
|
if (s_include_t) {
|
||||||
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
||||||
}
|
}
|
||||||
std::string filename(s_filename);
|
// Sanitize the typed filename (basename only, no traversal) and reflect it back in the
|
||||||
|
// buffer so the user sees exactly what gets written. Mirrors the CSV export dialog.
|
||||||
|
std::string filename = util::sanitizeExportFilename(s_filename, ".txt", "dragonx_keys.txt");
|
||||||
|
snprintf(s_filename, sizeof(s_filename), "%s", filename.c_str());
|
||||||
|
|
||||||
// Run all key exports on worker thread
|
// Run all key exports on worker thread
|
||||||
if (app->worker()) {
|
if (app->worker()) {
|
||||||
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
|
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
#include "../../app.h"
|
#include "../../app.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/export_filename.h"
|
||||||
#include "../../data/transaction_csv.h"
|
#include "../../data/transaction_csv.h"
|
||||||
|
#include "transactions_tab.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -29,30 +31,23 @@ static bool s_open = false;
|
|||||||
static char s_filename[256] = "";
|
static char s_filename[256] = "";
|
||||||
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
||||||
static std::string s_error; // error message on failure; empty otherwise
|
static std::string s_error; // error message on failure; empty otherwise
|
||||||
|
static int s_scope = 0; // 0 = all, 1 = current History filter, 2 = date range
|
||||||
|
static char s_from[16] = ""; // YYYY-MM-DD (date-range scope)
|
||||||
|
static char s_to[16] = "";
|
||||||
|
|
||||||
// Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_',
|
// Parse a YYYY-MM-DD string to the local-time start-of-day unix seconds. Returns false on empty/invalid.
|
||||||
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
|
static bool parseYmd(const char* s, std::int64_t& outStartOfDay) {
|
||||||
static std::string sanitizeExportFilename(std::string name) {
|
if (!s || !*s) return false;
|
||||||
const size_t slash = name.find_last_of("/\\");
|
int y = 0, mo = 0, d = 0;
|
||||||
if (slash != std::string::npos) name = name.substr(slash + 1);
|
if (std::sscanf(s, "%d-%d-%d", &y, &mo, &d) != 3) return false;
|
||||||
auto notspace = [](unsigned char c) { return !std::isspace(c); };
|
if (y < 1970 || y > 9999 || mo < 1 || mo > 12 || d < 1 || d > 31) return false;
|
||||||
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notspace));
|
std::tm tm{}; tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; tm.tm_isdst = -1;
|
||||||
name.erase(std::find_if(name.rbegin(), name.rend(), notspace).base(), name.end());
|
std::time_t t = std::mktime(&tm);
|
||||||
for (char& c : name) {
|
if (t == (std::time_t)-1) return false;
|
||||||
const unsigned char u = static_cast<unsigned char>(c);
|
// Reject rollover (e.g. Feb 31 → Mar) by requiring the normalized fields to round-trip.
|
||||||
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*')
|
if (tm.tm_year != y - 1900 || tm.tm_mon != mo - 1 || tm.tm_mday != d) return false;
|
||||||
c = '_';
|
outStartOfDay = (std::int64_t)t;
|
||||||
}
|
return true;
|
||||||
const 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);
|
|
||||||
std::string lower = name;
|
|
||||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
|
||||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
|
||||||
if (lower.size() < 4 || lower.compare(lower.size() - 4, 4, ".csv") != 0) name += ".csv";
|
|
||||||
if (name.empty() || name == ".csv") name = "dragonx_transactions.csv";
|
|
||||||
return name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ExportTransactionsDialog::show()
|
void ExportTransactionsDialog::show()
|
||||||
@@ -60,6 +55,10 @@ void ExportTransactionsDialog::show()
|
|||||||
s_open = true;
|
s_open = true;
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
|
// Pre-select the current History filter only when one is actually applied.
|
||||||
|
s_scope = GetTransactionsFilterSnapshot().active ? 1 : 0;
|
||||||
|
s_from[0] = '\0';
|
||||||
|
s_to[0] = '\0';
|
||||||
|
|
||||||
// Generate default filename with timestamp
|
// Generate default filename with timestamp
|
||||||
std::time_t now = std::time(nullptr);
|
std::time_t now = std::time(nullptr);
|
||||||
@@ -101,8 +100,64 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (!m::BeginOverlayDialog(ov)) return;
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
const auto& state = app->getWalletState();
|
const auto& state = app->getWalletState();
|
||||||
|
const TxFilterSnapshot& snap = GetTransactionsFilterSnapshot();
|
||||||
|
const bool filterUsable = snap.valid && snap.active && snap.txRevision == state.tx_revision;
|
||||||
|
|
||||||
ImGui::Text(TR("export_tx_count"), state.transactions.size());
|
// ---- Export scope ----
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("export_tx_scope"));
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_all"), &s_scope, 0);
|
||||||
|
if (!filterUsable) ImGui::BeginDisabled();
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_filter"), &s_scope, 1);
|
||||||
|
if (!filterUsable) ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !filterUsable)
|
||||||
|
m::Tooltip("%s", TR("export_tx_scope_filter_none"));
|
||||||
|
if (s_scope == 1 && filterUsable && !snap.description.empty()) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "(%s)", snap.description.c_str());
|
||||||
|
}
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_range"), &s_scope, 2);
|
||||||
|
|
||||||
|
std::int64_t fromT = 0, toT = 0; bool fromOk = false, toOk = false, rangeBad = false;
|
||||||
|
if (s_scope == 2) {
|
||||||
|
ImGui::Indent();
|
||||||
|
const float dw = 130.0f * Layout::dpiScale();
|
||||||
|
ImGui::SetNextItemWidth(dw);
|
||||||
|
ImGui::InputTextWithHint("##exfrom", TR("export_tx_range_from"), s_from, sizeof(s_from));
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetNextItemWidth(dw);
|
||||||
|
ImGui::InputTextWithHint("##exto", TR("export_tx_range_to"), s_to, sizeof(s_to));
|
||||||
|
fromOk = parseYmd(s_from, fromT);
|
||||||
|
toOk = parseYmd(s_to, toT);
|
||||||
|
if (toOk) toT += 24 * 3600 - 1; // inclusive end-of-day
|
||||||
|
if ((s_from[0] && !fromOk) || (s_to[0] && !toOk)) rangeBad = true;
|
||||||
|
if (fromOk && toOk && fromT > toT) rangeBad = true;
|
||||||
|
if (rangeBad)
|
||||||
|
ImGui::TextColored(m::ErrorVec4(), "%s", TR("export_tx_range_invalid"));
|
||||||
|
else
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("export_tx_range_pending_note"));
|
||||||
|
ImGui::Unindent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the subset for the chosen scope (scopeOk=false ⇒ can't export: stale snapshot / bad range).
|
||||||
|
std::vector<size_t> subsetIdx; bool scopeOk = true;
|
||||||
|
if (s_scope == 1) {
|
||||||
|
if (!filterUsable) scopeOk = false;
|
||||||
|
else for (size_t i : snap.indices) if (i < state.transactions.size()) subsetIdx.push_back(i);
|
||||||
|
} else if (s_scope == 2) {
|
||||||
|
if (rangeBad) scopeOk = false;
|
||||||
|
else for (size_t i = 0; i < state.transactions.size(); ++i) {
|
||||||
|
const auto& tx = state.transactions[i];
|
||||||
|
if (tx.timestamp <= 0) continue; // pending rows have no date
|
||||||
|
if (fromOk && tx.timestamp < fromT) continue;
|
||||||
|
if (toOk && tx.timestamp > toT) continue;
|
||||||
|
subsetIdx.push_back(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (size_t i = 0; i < state.transactions.size(); ++i) subsetIdx.push_back(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Text(TR("export_tx_count"), scopeOk ? subsetIdx.size() : (size_t)0);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Filename + save-location hint.
|
// Filename + save-location hint.
|
||||||
@@ -138,18 +193,29 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (doExport) {
|
if (doExport) {
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
if (state.transactions.empty()) {
|
if (!scopeOk) {
|
||||||
|
// Stale filter snapshot (tx list changed) or an invalid date range — never silently export all.
|
||||||
|
Notifications::instance().warning(s_scope == 1 ? TR("export_tx_scope_stale") : TR("export_tx_range_invalid"));
|
||||||
|
} else if (subsetIdx.empty()) {
|
||||||
Notifications::instance().warning(TR("export_tx_none"));
|
Notifications::instance().warning(TR("export_tx_none"));
|
||||||
} else {
|
} else {
|
||||||
std::string configDir = util::Platform::getConfigDir();
|
std::string configDir = util::Platform::getConfigDir();
|
||||||
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
// 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).
|
// the config dir by a pasted path).
|
||||||
std::string safeName = sanitizeExportFilename(s_filename);
|
std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
|
||||||
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
||||||
std::string filepath = configDir + "/" + safeName;
|
std::string filepath = configDir + "/" + safeName;
|
||||||
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
|
// Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
|
||||||
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
|
// (temp + fsync + rename) so a disk-full failure is reported instead of a truncated CSV.
|
||||||
const std::string csv = data::formatTransactionsCsv(state.transactions);
|
std::string csv;
|
||||||
|
if (s_scope == 0) {
|
||||||
|
csv = data::formatTransactionsCsv(state.transactions);
|
||||||
|
} else {
|
||||||
|
std::vector<TransactionInfo> subset;
|
||||||
|
subset.reserve(subsetIdx.size());
|
||||||
|
for (size_t i : subsetIdx) subset.push_back(state.transactions[i]);
|
||||||
|
csv = data::formatTransactionsCsv(subset);
|
||||||
|
}
|
||||||
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
||||||
s_error = TR("export_tx_file_fail");
|
s_error = TR("export_tx_file_fail");
|
||||||
Notifications::instance().error(TR("export_tx_file_fail"));
|
Notifications::instance().error(TR("export_tx_file_fail"));
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ void KeyExportDialog::render(App* app)
|
|||||||
if (s_fetching) {
|
if (s_fetching) {
|
||||||
ImGui::TextColored(material::WarningVec4(), "%s%s", TR("key_export_fetching_base"), material::LoadingDots());
|
ImGui::TextColored(material::WarningVec4(), "%s%s", TR("key_export_fetching_base"), material::LoadingDots());
|
||||||
} else if (!s_error.empty()) {
|
} else if (!s_error.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str());
|
ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error.c_str());
|
||||||
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
|
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
@@ -132,7 +132,7 @@ void KeyExportDialog::render(App* app)
|
|||||||
} else if (s_key.empty() && s_address.length() < 26) {
|
} else if (s_key.empty() && s_address.length() < 26) {
|
||||||
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
|
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
|
||||||
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
|
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("invalid_address"));
|
ImGui::TextColored(material::ErrorVec4(), "%s", TR("invalid_address"));
|
||||||
} else if (s_key.empty() && app->getWalletState().isLocked()) {
|
} else if (s_key.empty() && app->getWalletState().isLocked()) {
|
||||||
// Reveal would just fail with a locked-wallet RPC error — say so and offer to unlock.
|
// Reveal would just fail with a locked-wallet RPC error — say so and offer to unlock.
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||||
|
|||||||
@@ -776,9 +776,11 @@ void RenderPeersTab(App* app)
|
|||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||||
ImGui::SetClipboardText(peer.addr.c_str());
|
ImGui::SetClipboardText(peer.addr.c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
||||||
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
||||||
@@ -892,6 +894,7 @@ void RenderPeersTab(App* app)
|
|||||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||||
ImGui::SetClipboardText(banned.address.c_str());
|
ImGui::SetClipboardText(banned.address.c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("peers_unban"))) {
|
if (ImGui::MenuItem(TR("peers_unban"))) {
|
||||||
app->unbanPeer(banned.address);
|
app->unbanPeer(banned.address);
|
||||||
|
|||||||
@@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
|
|||||||
} else {
|
} else {
|
||||||
s_generating_address = true;
|
s_generating_address = true;
|
||||||
if (s_addr_type_filter != 2) {
|
if (s_addr_type_filter != 2) {
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
@@ -400,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float
|
|||||||
s_cached_qr_data.clear();
|
s_cached_qr_data.clear();
|
||||||
} else {
|
} else {
|
||||||
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
||||||
if (app->isConnected()) {
|
if (app->isConnected()) {
|
||||||
s_creating_address = true;
|
s_creating_address = true;
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_creating_address = false;
|
s_creating_address = false;
|
||||||
if (addr.empty()) {
|
if (addr.empty()) {
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
} else {
|
} else {
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
// Pre-select the new address if the dialog is still open and none chosen.
|
// Pre-select the new address if the dialog is still open and none chosen.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include "../layout.h" // Layout::dpiScale()
|
#include "../layout.h" // Layout::dpiScale()
|
||||||
@@ -15,19 +16,31 @@
|
|||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
// Best-effort zero of a seed-word vector (each word holds part of the phrase). Call after rendering.
|
||||||
|
inline void WipeSeedWords(std::vector<std::string>& words)
|
||||||
|
{
|
||||||
|
for (auto& w : words) { if (!w.empty()) sodium_memzero(&w[0], w.size()); w.clear(); }
|
||||||
|
words.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Split a whitespace-separated seed phrase into its individual words.
|
// Split a whitespace-separated seed phrase into its individual words.
|
||||||
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
||||||
{
|
{
|
||||||
std::vector<std::string> words;
|
std::vector<std::string> words;
|
||||||
|
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
|
||||||
std::string cur;
|
std::string cur;
|
||||||
for (char c : phrase) {
|
for (char c : phrase) {
|
||||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||||
if (!cur.empty()) { words.push_back(cur); cur.clear(); }
|
if (!cur.empty()) {
|
||||||
|
words.push_back(cur);
|
||||||
|
sodium_memzero(&cur[0], cur.size()); // zero the scratch copy before reuse
|
||||||
|
cur.clear();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cur.push_back(c);
|
cur.push_back(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cur.empty()) words.push_back(cur);
|
if (!cur.empty()) { words.push_back(cur); sodium_memzero(&cur[0], cur.size()); cur.clear(); }
|
||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +58,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
|||||||
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
||||||
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
||||||
if (need > colStride) colStride = need;
|
if (need > colStride) colStride = need;
|
||||||
|
sodium_memzero(cell, sizeof(cell)); // the cell held a seed word
|
||||||
}
|
}
|
||||||
for (size_t i = 0; i < words.size(); ++i) {
|
for (size_t i = 0; i < words.size(); ++i) {
|
||||||
char cell[96];
|
char cell[96];
|
||||||
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
|||||||
ImGui::TextUnformatted(cell);
|
ImGui::TextUnformatted(cell);
|
||||||
if ((i % 4) != 3 && i + 1 < words.size())
|
if ((i % 4) != 3 && i + 1 < words.size())
|
||||||
ImGui::SameLine(((i % 4) + 1) * colStride);
|
ImGui::SameLine(((i % 4) + 1) * colStride);
|
||||||
|
sodium_memzero(cell, sizeof(cell));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
#include "../../util/address_validation.h"
|
#include "../../util/address_validation.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/text_format.h"
|
#include "../../util/text_format.h"
|
||||||
|
#include "../../util/amount_format.h" // toSats / canAfford — integer-satoshi affordability check
|
||||||
|
#include "../../rpc/rpc_failure.h" // isSendMaybeInFlight — lost-reply (outcome unknown) send tag
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "contact_picker.h"
|
#include "contact_picker.h"
|
||||||
@@ -915,8 +917,9 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
// since Review. Don't broadcast a now-invalid transaction.
|
// since Review. Don't broadcast a now-invalid transaction.
|
||||||
const auto& liveState = app->getWalletState();
|
const auto& liveState = app->getWalletState();
|
||||||
double liveAvail = GetAvailableBalance(app);
|
double liveAvail = GetAvailableBalance(app);
|
||||||
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
|
if (!app->isConnected() || liveState.sync.syncing ||
|
||||||
(s_amount + s_fee) > liveAvail) {
|
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
|
||||||
|
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
||||||
s_tx_status = TR("send_cannot_send_now");
|
s_tx_status = TR("send_cannot_send_now");
|
||||||
s_status_success = false;
|
s_status_success = false;
|
||||||
s_status_timestamp = ImGui::GetTime();
|
s_status_timestamp = ImGui::GetTime();
|
||||||
@@ -954,6 +957,18 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
s_amount = 0.0;
|
s_amount = 0.0;
|
||||||
s_memo[0] = '\0';
|
s_memo[0] = '\0';
|
||||||
s_send_max = false;
|
s_send_max = false;
|
||||||
|
} else if (rpc::isSendMaybeInFlight(result)) {
|
||||||
|
// Outcome unknown: the reply was lost after the request left. Do NOT present a
|
||||||
|
// plain failure (it invites a retry/double-send). Warn, point at History, and
|
||||||
|
// clear the form so a resend has to be retyped deliberately.
|
||||||
|
s_tx_status = std::string(TR("send_maybe_sent")) + "\n\n" + rpc::stripSendMaybeInFlight(result);
|
||||||
|
s_result_txid.clear();
|
||||||
|
s_status_success = false;
|
||||||
|
Notifications::instance().warning(TR("send_maybe_sent"), 15.0f);
|
||||||
|
s_to_address[0] = '\0';
|
||||||
|
s_amount = 0.0;
|
||||||
|
s_memo[0] = '\0';
|
||||||
|
s_send_max = false;
|
||||||
} else {
|
} else {
|
||||||
// Show a friendly cause in the toast; keep the raw backend text below it in
|
// Show a friendly cause in the toast; keep the raw backend text below it in
|
||||||
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
||||||
@@ -1034,10 +1049,11 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
}
|
}
|
||||||
bool can_send = app->isConnected() &&
|
bool can_send = app->isConnected() &&
|
||||||
!state.sync.syncing &&
|
!state.sync.syncing &&
|
||||||
|
state.sync.sendTipReady(app->isLiteBuild()) &&
|
||||||
is_valid_address &&
|
is_valid_address &&
|
||||||
s_amount > 0 &&
|
s_amount > 0 &&
|
||||||
s_from_address[0] != '\0' &&
|
s_from_address[0] != '\0' &&
|
||||||
total <= available &&
|
util::canAfford(s_amount, s_fee, available) &&
|
||||||
sourceSpendable &&
|
sourceSpendable &&
|
||||||
!s_sending;
|
!s_sending;
|
||||||
|
|
||||||
@@ -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);
|
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).
|
// 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"));
|
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
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"));
|
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
||||||
else if (state.sync.syncing)
|
else if (state.sync.syncing)
|
||||||
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||||
|
else if (!state.sync.sendTipReady(app->isLiteBuild()))
|
||||||
|
material::Tooltip("%s", TR("send_tooltip_no_tip"));
|
||||||
else if (s_from_address[0] == '\0')
|
else if (s_from_address[0] == '\0')
|
||||||
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
||||||
else if (is_mining_addr)
|
else if (is_mining_addr)
|
||||||
@@ -1095,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
||||||
else if (s_amount <= 0)
|
else if (s_amount <= 0)
|
||||||
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
||||||
else if (total > available)
|
else if (!util::canAfford(s_amount, s_fee, available))
|
||||||
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
||||||
else if (!sourceSpendable)
|
else if (!sourceSpendable)
|
||||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||||
@@ -1530,6 +1548,7 @@ void RenderSendTab(App* app)
|
|||||||
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
||||||
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
||||||
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
||||||
{
|
{
|
||||||
@@ -1555,6 +1574,7 @@ void RenderSendTab(App* app)
|
|||||||
if (s_amount < 0) s_amount = 0;
|
if (s_amount < 0) s_amount = 0;
|
||||||
if (market.price_usd > 0)
|
if (market.price_usd > 0)
|
||||||
s_usd_amount = s_amount * market.price_usd;
|
s_usd_amount = s_amount * market.price_usd;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw USD equivalent inside the input field (right-aligned overlay)
|
// Draw USD equivalent inside the input field (right-aligned overlay)
|
||||||
if (market.price_usd > 0 && s_amount > 0) {
|
if (market.price_usd > 0 && s_amount > 0) {
|
||||||
@@ -1619,7 +1639,7 @@ void RenderSendTab(App* app)
|
|||||||
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
||||||
|
|
||||||
// Amount error
|
// Amount error
|
||||||
if (s_amount > 0 && s_amount + s_fee > available && available > 0) {
|
if (s_amount > 0 && !util::canAfford(s_amount, s_fee, available) && available > 0) {
|
||||||
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
||||||
Type().textColored(TypeStyle::Caption, Error(), buf);
|
Type().textColored(TypeStyle::Caption, Error(), buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,16 +58,16 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
ImVec4 type_color;
|
ImVec4 type_color;
|
||||||
std::string type_display;
|
std::string type_display;
|
||||||
if (tx.type == "receive") {
|
if (tx.type == "receive") {
|
||||||
type_color = ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
|
type_color = material::SuccessVec4();
|
||||||
type_display = TR("tx_received");
|
type_display = TR("tx_received");
|
||||||
} else if (tx.type == "send") {
|
} else if (tx.type == "send") {
|
||||||
type_color = ImVec4(0.8f, 0.3f, 0.3f, 1.0f);
|
type_color = material::ErrorVec4();
|
||||||
type_display = TR("tx_sent");
|
type_display = TR("tx_sent");
|
||||||
} else if (tx.type == "generate" || tx.type == "mined") {
|
} else if (tx.type == "generate" || tx.type == "mined") {
|
||||||
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
||||||
type_display = TR("tx_mined");
|
type_display = TR("tx_mined");
|
||||||
} else if (tx.type == "immature") {
|
} else if (tx.type == "immature") {
|
||||||
type_color = ImVec4(0.8f, 0.8f, 0.3f, 1.0f);
|
type_color = material::WarningVec4();
|
||||||
type_display = TR("tx_immature");
|
type_display = TR("tx_immature");
|
||||||
} else {
|
} else {
|
||||||
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
||||||
@@ -79,11 +79,11 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
|
|
||||||
// Confirmations badge
|
// Confirmations badge
|
||||||
if (tx.confirmations == 0) {
|
if (tx.confirmations == 0) {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("pending"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("pending"));
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations);
|
ImGui::TextColored(material::WarningVec4(), TR("tx_confirmations"), tx.confirmations);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations);
|
ImGui::TextColored(material::SuccessVec4(), TR("tx_confirmations"), tx.confirmations);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -94,9 +94,9 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
ImGui::Text("%s", TR("amount_label"));
|
ImGui::Text("%s", TR("amount_label"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (tx.amount >= 0) {
|
if (tx.amount >= 0) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "+%.8f DRGX", tx.amount);
|
ImGui::TextColored(material::SuccessVec4(), "+%.8f DRGX", tx.amount);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%.8f DRGX", tx.amount);
|
ImGui::TextColored(material::ErrorVec4(), "%.8f DRGX", tx.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD equivalent if price available
|
// USD equivalent if price available
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Released under the GPLv3
|
// Released under the GPLv3
|
||||||
|
|
||||||
#include "transactions_tab.h"
|
#include "transactions_tab.h"
|
||||||
|
#include "../notifications.h"
|
||||||
#include "transaction_details_dialog.h"
|
#include "transaction_details_dialog.h"
|
||||||
#include "export_transactions_dialog.h"
|
#include "export_transactions_dialog.h"
|
||||||
#include "transactions_tab_helpers.h"
|
#include "transactions_tab_helpers.h"
|
||||||
@@ -36,6 +37,11 @@ namespace ui {
|
|||||||
|
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
|
// Latest History-tab filter snapshot, published from the filtered-list rebuild below. Used by the
|
||||||
|
// transactions CSV export to offer "export only the current filter".
|
||||||
|
static TxFilterSnapshot s_filter_snapshot;
|
||||||
|
const TxFilterSnapshot& GetTransactionsFilterSnapshot() { return s_filter_snapshot; }
|
||||||
|
|
||||||
static std::string TrId(const char* key, const char* id) {
|
static std::string TrId(const char* key, const char* id) {
|
||||||
return std::string(TR(key)) + "##" + id;
|
return std::string(TR(key)) + "##" + id;
|
||||||
}
|
}
|
||||||
@@ -213,10 +219,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
||||||
// (a sort-combo change re-sorts without regrouping).
|
// (a sort-combo change re-sorts without regrouping).
|
||||||
static history::TxidGroups s_groups;
|
static history::TxidGroups s_groups;
|
||||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
|
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
|
||||||
if (s_groups_key != contentKey) {
|
const std::uint64_t structKey = state.tx_structure_revision; // ignores pure confirmation-count bumps
|
||||||
|
if (s_groups_key != structKey) {
|
||||||
s_groups = history::groupByTxid(state.transactions);
|
s_groups = history::groupByTxid(state.transactions);
|
||||||
s_groups_key = contentKey;
|
s_groups_key = structKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||||
@@ -409,13 +416,14 @@ void RenderTransactionsTab(App* app)
|
|||||||
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
||||||
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
||||||
static std::vector<DisplayTx> s_display_cache;
|
static std::vector<DisplayTx> s_display_cache;
|
||||||
static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from
|
static std::uint64_t s_display_cache_key = 0; // tx_structure_revision the cache was built from
|
||||||
|
static std::uint64_t s_display_conf_rev = 0; // tx_revision the cached confirmations were last synced to
|
||||||
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
||||||
static bool s_display_cache_valid = false;
|
static bool s_display_cache_valid = false;
|
||||||
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
||||||
|
|
||||||
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
||||||
if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
if (!s_display_cache_valid || structKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
||||||
++s_display_gen;
|
++s_display_gen;
|
||||||
s_display_cache.clear();
|
s_display_cache.clear();
|
||||||
std::vector<DisplayTx>& display_txns = s_display_cache;
|
std::vector<DisplayTx>& display_txns = s_display_cache;
|
||||||
@@ -518,9 +526,27 @@ void RenderTransactionsTab(App* app)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
s_display_cache_key = contentKey;
|
s_display_cache_key = structKey;
|
||||||
|
s_display_conf_rev = contentKey; // freshly built from the current list
|
||||||
s_display_cache_sort = s_sort_mode;
|
s_display_cache_sort = s_sort_mode;
|
||||||
s_display_cache_valid = true;
|
s_display_cache_valid = true;
|
||||||
|
} else if (s_display_conf_rev != contentKey) {
|
||||||
|
// Confirmation-count-only delta (setTransactionsIfChanged kept tx_structure_revision): the merged rows, their
|
||||||
|
// order and the indices into state.transactions are unchanged, so refresh just the count (same rule as the
|
||||||
|
// build above: a shield shows the larger of its two legs). Sort order cannot change: mode 0 only reads
|
||||||
|
// conf==0, which the layout check holds constant, and the other modes ignore confirmations.
|
||||||
|
for (DisplayTx& dtx : s_display_cache) {
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
dtx.confirmations = std::max(state.transactions[dtx.send_idx].confirmations,
|
||||||
|
state.transactions[dtx.recv_idx].confirmations);
|
||||||
|
} else {
|
||||||
|
dtx.confirmations = state.transactions[dtx.orig_idx].confirmations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The filtered-index cache keys on s_display_gen (unchanged, correct: filters ignore confirmations) and
|
||||||
|
// the snapshot's indices remain valid; only re-stamp it so 'export current filter' stays usable.
|
||||||
|
if (s_filter_snapshot.valid) s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_display_conf_rev = contentKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
||||||
@@ -565,6 +591,35 @@ void RenderTransactionsTab(App* app)
|
|||||||
s_filtered_search = search_str;
|
s_filtered_search = search_str;
|
||||||
s_filtered_chat_rev = chatRev;
|
s_filtered_chat_rev = chatRev;
|
||||||
s_filtered_valid = true;
|
s_filtered_valid = true;
|
||||||
|
|
||||||
|
// Publish a snapshot of the visible set for "export current filter". A merged autoshield row
|
||||||
|
// contributes BOTH original legs, so the CSV stays a per-leg record like an unfiltered export.
|
||||||
|
s_filter_snapshot.indices.clear();
|
||||||
|
s_filter_snapshot.indices.reserve(s_filtered_cache.size() * 2);
|
||||||
|
for (size_t fi : s_filtered_cache) {
|
||||||
|
const auto& dtx = display_txns[fi];
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
if (dtx.send_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.send_idx));
|
||||||
|
if (dtx.recv_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.recv_idx));
|
||||||
|
} else if (dtx.orig_idx >= 0) {
|
||||||
|
s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.orig_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(s_filter_snapshot.indices.begin(), s_filter_snapshot.indices.end());
|
||||||
|
s_filter_snapshot.active = (type_filter != 0 || !search_str.empty());
|
||||||
|
s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_filter_snapshot.valid = true;
|
||||||
|
{
|
||||||
|
const char* typeNames[] = { "", TR("sent_filter"), TR("received_filter"),
|
||||||
|
TR("mined_filter"), TR("chat_filter") };
|
||||||
|
std::string desc = (type_filter >= 1 && type_filter <= 4) ? typeNames[type_filter] : "";
|
||||||
|
if (!search_str.empty()) {
|
||||||
|
if (!desc.empty()) desc += ", ";
|
||||||
|
desc += "\"" + search_str + "\"";
|
||||||
|
}
|
||||||
|
if (desc.empty()) desc = TR("all_filter");
|
||||||
|
s_filter_snapshot.description = desc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const std::vector<size_t>& filtered_indices = s_filtered_cache;
|
const std::vector<size_t>& filtered_indices = s_filtered_cache;
|
||||||
filtered_count = static_cast<int>(filtered_indices.size());
|
filtered_count = static_cast<int>(filtered_indices.size());
|
||||||
@@ -800,7 +855,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
{
|
{
|
||||||
const char* statusStr;
|
const char* statusStr;
|
||||||
ImU32 statusCol;
|
ImU32 statusCol;
|
||||||
if (tx.confirmations == 0) {
|
const int64_t nowSec = static_cast<int64_t>(std::time(nullptr));
|
||||||
|
if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out
|
||||||
|
statusStr = TR("tx_dropped"); statusCol = Error();
|
||||||
|
} else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) {
|
||||||
|
statusStr = TR("tx_stale_pending"); statusCol = Warning();
|
||||||
|
} else if (tx.confirmations == 0) {
|
||||||
statusStr = TR("pending"); statusCol = Warning();
|
statusStr = TR("pending"); statusCol = Warning();
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
||||||
@@ -871,9 +931,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
||||||
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
||||||
ImGui::SetClipboardText(tx.address.c_str());
|
ImGui::SetClipboardText(tx.address.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("copy_txid"))) {
|
if (ImGui::MenuItem(TR("copy_txid"))) {
|
||||||
ImGui::SetClipboardText(tx.txid.c_str());
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
||||||
@@ -916,7 +978,10 @@ void RenderTransactionsTab(App* app)
|
|||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
||||||
ImGui::TextWrapped("%s", tx.txid.c_str());
|
ImGui::TextWrapped("%s", tx.txid.c_str());
|
||||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str());
|
if (ImGui::IsItemClicked()) {
|
||||||
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
// Memo
|
// Memo
|
||||||
@@ -938,10 +1003,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
||||||
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(tx.txid.c_str());
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(tx.address.c_str());
|
ImGui::SetClipboardText(tx.address.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
|
|||||||
@@ -5,12 +5,34 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
class App;
|
class App;
|
||||||
|
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Snapshot of what the History tab currently shows, for "export current filter".
|
||||||
|
*
|
||||||
|
* `indices` are positions into WalletState::transactions of every leg currently visible (a merged
|
||||||
|
* autoshield row contributes both its send and receive legs), sorted ascending. Published only when the
|
||||||
|
* filtered list is (re)built, so it reflects the last state the History tab rendered. `txRevision` lets a
|
||||||
|
* consumer reject a snapshot that predates the current tx list.
|
||||||
|
*/
|
||||||
|
struct TxFilterSnapshot {
|
||||||
|
bool valid = false;
|
||||||
|
bool active = false; // a type filter and/or search text is applied
|
||||||
|
std::uint64_t txRevision = 0;
|
||||||
|
std::string description; // human label of the active filter
|
||||||
|
std::vector<std::size_t> indices;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The latest History-tab filter snapshot (see TxFilterSnapshot).
|
||||||
|
const TxFilterSnapshot& GetTransactionsFilterSnapshot();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Render the Transactions tab
|
* @brief Render the Transactions tab
|
||||||
* Shows transaction history
|
* Shows transaction history
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
|
|
||||||
if (s_validating) {
|
if (s_validating) {
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("validating"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("validating"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -194,15 +194,15 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
if (!s_error_message.empty()) {
|
if (!s_error_message.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error_message.c_str());
|
ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error_message.c_str());
|
||||||
} else {
|
} else {
|
||||||
// Valid/Invalid indicator
|
// Valid/Invalid indicator
|
||||||
ImGui::Text("%s", TR("validate_status"));
|
ImGui::Text("%s", TR("validate_status"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (s_is_valid) {
|
if (s_is_valid) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_valid"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_valid"));
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("validate_invalid"));
|
ImGui::TextColored(material::ErrorVec4(), "%s", TR("validate_invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s_is_valid) {
|
if (s_is_valid) {
|
||||||
@@ -219,7 +219,7 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
||||||
: "validate_ownership_unknown_offline"));
|
: "validate_ownership_unknown_offline"));
|
||||||
} else if (s_is_mine) {
|
} 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 {
|
} else {
|
||||||
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
|
|
||||||
std::string formatAmountFixed(double amount, int decimals = 8);
|
std::string formatAmountFixed(double amount, int decimals = 8);
|
||||||
|
|
||||||
|
// DRGX amounts are 8-decimal. Converting to int64 satoshis and comparing there avoids the IEEE-double
|
||||||
|
// rounding that could block a valid exact-max send (amount+fee computed in doubles landed 1 ulp over).
|
||||||
|
inline std::int64_t toSats(double coins) { return static_cast<std::int64_t>(std::llround(coins * 1e8)); }
|
||||||
|
|
||||||
|
// True when amount + fee fits within available, compared in integer satoshis (not doubles).
|
||||||
|
inline bool canAfford(double amount, double fee, double available) {
|
||||||
|
return toSats(amount) + toSats(fee) <= toSats(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a coin amount to zatoshis for a send, rejecting non-finite / non-positive / rounds-to-zero /
|
||||||
|
// over-supply values. Returns false (and leaves out untouched) on any of those.
|
||||||
|
inline bool coinsToZatoshis(double coins, std::uint64_t& out) {
|
||||||
|
if (!std::isfinite(coins) || coins <= 0.0) return false;
|
||||||
|
const std::int64_t z = toSats(coins);
|
||||||
|
if (z <= 0 || z > static_cast<std::int64_t>(21000000) * 100000000LL) return false;
|
||||||
|
out = static_cast<std::uint64_t>(z);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+22
-45
@@ -1125,56 +1125,33 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
|
|||||||
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
||||||
const bool haveSHA256 = !expectedSha.empty();
|
const bool haveSHA256 = !expectedSha.empty();
|
||||||
|
|
||||||
std::string expectedMd5;
|
// SHA-256 is now required: the collision-prone MD5 fallback was removed. (A same-origin checksum
|
||||||
bool haveMD5 = false;
|
// only protects against transport corruption, not a malicious server — true authenticity needs a
|
||||||
if (!haveSHA256) { // MD5 only as a fallback when no SHA-256 is published
|
// detached ed25519 signature like the daemon/xmrig updaters; that is a separate release-coordinated
|
||||||
std::string md5Content = httpGetString(baseUrl + "/" + kZipName + ".md5", "[bootstrap]", opt);
|
// change, since it requires publishing per-archive .sig files and pinning a key.)
|
||||||
if (cancel_requested_) return false;
|
if (!haveSHA256) {
|
||||||
expectedMd5 = parseChecksumFile(md5Content, 32);
|
DEBUG_LOGF("[Bootstrap] No SHA-256 published - refusing to install an unverified archive\n");
|
||||||
haveMD5 = !expectedMd5.empty();
|
setProgress(State::Failed, "Verification failed: no SHA-256 checksum was published for the bootstrap. "
|
||||||
}
|
|
||||||
|
|
||||||
if (!haveSHA256 && !haveMD5) {
|
|
||||||
DEBUG_LOGF("[Bootstrap] No checksum published - refusing to install an unverified archive\n");
|
|
||||||
setProgress(State::Failed, "Verification failed: no checksum was published for the bootstrap. "
|
|
||||||
"Refusing to install an unverified archive.");
|
"Refusing to install an unverified archive.");
|
||||||
return false; // fail CLOSED; archive is KEPT
|
return false; // fail CLOSED; archive is KEPT
|
||||||
}
|
}
|
||||||
|
|
||||||
if (haveSHA256) {
|
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
||||||
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
||||||
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
if (cancel_requested_) return false;
|
||||||
if (cancel_requested_) return false;
|
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
|
||||||
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
|
if (expectedSha != actual) {
|
||||||
if (expectedSha != actual) {
|
last_verify_mismatch_ = true;
|
||||||
last_verify_mismatch_ = true;
|
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
|
||||||
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
|
expectedSha.c_str(), actual.c_str());
|
||||||
expectedSha.c_str(), actual.c_str());
|
setProgress(State::Failed,
|
||||||
setProgress(State::Failed,
|
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
||||||
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
"Expected: " + expectedSha.substr(0, 16) + "...\n"
|
||||||
"Expected: " + expectedSha.substr(0, 16) + "...\n"
|
"Got: " + actual.substr(0, 16) + "...\n"
|
||||||
"Got: " + actual.substr(0, 16) + "...\n"
|
"Try again or use a fresh download.");
|
||||||
"Try again or use a fresh download.");
|
return false;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
|
||||||
|
|
||||||
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
// Make a user-typed filename safe to write into a trusted config/export directory: basename only
|
||||||
|
// (no path traversal), illegal / control chars -> '_', no leading dots or trailing dots-or-spaces,
|
||||||
|
// Windows reserved device stems prefixed with '_', length-capped, and always ending in requiredExt
|
||||||
|
// (e.g. ".csv"). Returns `fallback` if nothing usable remains. Header-only so every export dialog
|
||||||
|
// shares one implementation.
|
||||||
|
inline std::string sanitizeExportFilename(std::string name, const char* requiredExt, const char* fallback) {
|
||||||
|
const std::size_t slash = name.find_last_of("/\\");
|
||||||
|
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||||
|
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||||
|
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notSpace));
|
||||||
|
name.erase(std::find_if(name.rbegin(), name.rend(), notSpace).base(), name.end());
|
||||||
|
for (char& c : name) {
|
||||||
|
const unsigned char u = static_cast<unsigned char>(c);
|
||||||
|
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' ||
|
||||||
|
c == '?' || c == '*' || c == '/' || c == '\\')
|
||||||
|
c = '_';
|
||||||
|
}
|
||||||
|
const std::size_t firstGood = name.find_first_not_of('.');
|
||||||
|
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||||
|
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||||
|
if (name.size() > 120) name.resize(120);
|
||||||
|
// Windows reserved device names (case-insensitive, checked on the stem before the first dot).
|
||||||
|
{
|
||||||
|
std::string stem = name;
|
||||||
|
const std::size_t dot = stem.find('.');
|
||||||
|
if (dot != std::string::npos) stem = stem.substr(0, dot);
|
||||||
|
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||||
|
static const char* const kReserved[] = {
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
};
|
||||||
|
for (const char* r : kReserved) if (stem == r) { name = "_" + name; break; }
|
||||||
|
}
|
||||||
|
std::string lower = name;
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
std::string lext = requiredExt ? requiredExt : "";
|
||||||
|
std::transform(lext.begin(), lext.end(), lext.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
if (!lext.empty() &&
|
||||||
|
(lower.size() < lext.size() || lower.compare(lower.size() - lext.size(), lext.size(), lext) != 0))
|
||||||
|
name += requiredExt;
|
||||||
|
if (name.empty() || (requiredExt && name == requiredExt)) name = fallback ? fallback : "export";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -60,6 +60,20 @@ int cancelXferCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
|
|||||||
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restrict the transfer AND any redirect to HTTPS so a redirect can't silently downgrade to http://,
|
||||||
|
// file://, ftp:// or an unexpected scheme. Every caller of these shared helpers (daemon/xmrig updaters,
|
||||||
|
// pool stats) uses https URLs. (Verify-peer/host stay on at the call sites.)
|
||||||
|
void restrictToHttps(CURL* curl)
|
||||||
|
{
|
||||||
|
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0: the string-form protocol setopts
|
||||||
|
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||||
|
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||||
|
#else
|
||||||
|
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
||||||
@@ -78,6 +92,7 @@ HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetO
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
||||||
|
restrictToHttps(curl);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
||||||
@@ -131,6 +146,8 @@ bool httpDownloadToFile(const std::string& url, const std::string& destPath,
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // bound redirects (this path set none before)
|
||||||
|
restrictToHttps(curl);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ void I18n::overlayTranslations(const nlohmann::json& translations)
|
|||||||
|
|
||||||
bool I18n::loadLanguage(const std::string& locale)
|
bool I18n::loadLanguage(const std::string& locale)
|
||||||
{
|
{
|
||||||
|
// Exclusive for the whole load: strings_ is cleared/rebuilt below, and translateCopy() readers on
|
||||||
|
// other threads (daemon monitor) must never observe it mid-rewrite. loadBuiltinEnglish() and
|
||||||
|
// overlayTranslations() are called only from here and do not lock (no recursion).
|
||||||
|
std::unique_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
|
||||||
// Try to load from file: CWD-relative first, then exe-relative
|
// Try to load from file: CWD-relative first, then exe-relative
|
||||||
std::string rel_path = "res/lang/" + locale + ".json";
|
std::string rel_path = "res/lang/" + locale + ".json";
|
||||||
const std::string exe_dir = Platform::getExecutableDirectory();
|
const std::string exe_dir = Platform::getExecutableDirectory();
|
||||||
@@ -482,6 +487,21 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_receive_addr"] = "New wallet receive address:";
|
strings_["mig_receive_addr"] = "New wallet receive address:";
|
||||||
strings_["mig_copy_seed"] = "Copy seed";
|
strings_["mig_copy_seed"] = "Copy seed";
|
||||||
strings_["mig_backed_up"] = "I've written down my seed phrase";
|
strings_["mig_backed_up"] = "I've written down my seed phrase";
|
||||||
|
strings_["err_not_connected_daemon"] = "Not connected to the daemon.";
|
||||||
|
strings_["sweep_err_need_spending_key"] = "Enter a spending key (a viewing key can't move funds).";
|
||||||
|
strings_["sweep_status_importing"] = "Importing key & rescanning…";
|
||||||
|
strings_["sweep_err_unknown"] = "Sweep failed (unknown error)";
|
||||||
|
strings_["sweep_err_nofunds"] = "This key holds no funds to sweep.";
|
||||||
|
strings_["sweep_err_unconfirmed"] = "This key's funds are still unconfirmed — try again shortly.";
|
||||||
|
strings_["sweep_err_already"] = "This key is already in your wallet — use Send to move its funds.";
|
||||||
|
strings_["sweep_err_dust"] = "This key's balance is too small to cover the network fee.";
|
||||||
|
strings_["sweep_status_sweeping"] = "Sweeping funds to your address…";
|
||||||
|
strings_["migrate_status_checking_sweep"] = "Checking on the previous sweep…";
|
||||||
|
strings_["migrate_err_no_dest"] = "No destination address for the sweep.";
|
||||||
|
strings_["migrate_status_building"] = "Building the sweep transaction…";
|
||||||
|
strings_["migrate_status_waiting_accept"] = "Waiting for the sweep transaction to be accepted…";
|
||||||
|
strings_["migrate_status_restarting"] = "Restarting with your new wallet — this rescan can take a while…";
|
||||||
|
strings_["rescan_status_from_block"] = "Rescanning from block %d...";
|
||||||
strings_["mig_understood"] = "I understand that once I continue, my funds will be moved into this new wallet and can only be recovered with this seed phrase";
|
strings_["mig_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_ack_required"] = "Confirm both statements above to continue";
|
||||||
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
|
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
|
||||||
@@ -492,6 +512,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_to"] = "To: ";
|
strings_["mig_to"] = "To: ";
|
||||||
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
||||||
strings_["mig_sweep_all"] = "Sweep all funds";
|
strings_["mig_sweep_all"] = "Sweep all funds";
|
||||||
|
strings_["mig_unlock_prompt"] = "Your wallet is locked. Enter your passphrase to unlock it, then sweep.";
|
||||||
|
strings_["mig_unlock_btn"] = "Unlock";
|
||||||
strings_["mig_later"] = "Later";
|
strings_["mig_later"] = "Later";
|
||||||
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
||||||
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
||||||
@@ -506,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"] = "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_sweep_confirm_yes"] = "Yes, sweep funds";
|
||||||
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
||||||
|
strings_["mig_waiting_depth"] = "Sweep mined. Waiting for %d confirmations before switching wallets, so a short chain reorganisation cannot undo it…";
|
||||||
|
strings_["mig_adopt_reverify_failed"] = "Could not confirm it is safe to switch wallets yet (the node may still be syncing, the sweep is not deep enough, or funds remain). Nothing was changed - wait and try again.";
|
||||||
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
||||||
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
||||||
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
||||||
@@ -606,6 +630,15 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["settings_merge_to_address"] = "Merge to Address…";
|
strings_["settings_merge_to_address"] = "Merge to Address…";
|
||||||
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
||||||
strings_["settings_import_key"] = "Import Private Key…";
|
strings_["settings_import_key"] = "Import Private Key…";
|
||||||
|
strings_["settings_import_wallet_file"] = "Import From Backup File…";
|
||||||
|
strings_["import_wallet_file_title"] = "Import keys from a backup file";
|
||||||
|
strings_["import_wallet_file_note"] = "Imports every private key from an old wallet's export file (its z_exportwallet / dumpwallet output, or SilentDragon's \"Export all private keys\") and rescans once — the fastest way to recover a legacy wallet.dat this client can't open.";
|
||||||
|
strings_["import_wallet_file_path_label"] = "Path to the key dump file:";
|
||||||
|
strings_["import_wallet_file_import_btn"] = "Import and rescan";
|
||||||
|
strings_["import_wallet_file_importing"] = "Importing keys and rescanning — this can take a while";
|
||||||
|
strings_["import_wallet_file_ok"] = "Keys imported. Once the rescan finishes, shield any mined (coinbase) transparent funds from the Shield screen to make them spendable.";
|
||||||
|
strings_["import_wallet_file_need_path"] = "Enter the path to the key dump file.";
|
||||||
|
strings_["import_wallet_file_not_found"] = "Couldn't open that file — check the path is correct and the file is readable.";
|
||||||
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
||||||
strings_["settings_export_key"] = "Export Key…";
|
strings_["settings_export_key"] = "Export Key…";
|
||||||
strings_["settings_export_all"] = "Export All…";
|
strings_["settings_export_all"] = "Export All…";
|
||||||
@@ -1075,6 +1108,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
|
|
||||||
// Settings: additional tooltips (keys/data row)
|
// Settings: additional tooltips (keys/data row)
|
||||||
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
||||||
|
strings_["tt_import_wallet_file"] = "Import all keys from an old wallet's exported dump file (one rescan)";
|
||||||
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
||||||
strings_["tt_export_key"] = "Export the private key for the selected address";
|
strings_["tt_export_key"] = "Export the private key for the selected address";
|
||||||
strings_["tt_export_all"] = "Export all private keys to a file";
|
strings_["tt_export_all"] = "Export all private keys to a file";
|
||||||
@@ -1337,6 +1371,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["lite_unlock_btn"] = "Unlock";
|
strings_["lite_unlock_btn"] = "Unlock";
|
||||||
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
||||||
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
||||||
|
strings_["lite_encrypt_failed_unencrypted"] = "Wallet encryption failed — your wallet may still be UNENCRYPTED. Open Settings > Security and set the passphrase again.";
|
||||||
|
|
||||||
// Send Tab
|
// Send Tab
|
||||||
strings_["pay_from"] = "Pay From";
|
strings_["pay_from"] = "Pay From";
|
||||||
@@ -1488,6 +1523,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["confirmations"] = "Confirmations";
|
strings_["confirmations"] = "Confirmations";
|
||||||
strings_["confirmed"] = "Confirmed";
|
strings_["confirmed"] = "Confirmed";
|
||||||
strings_["pending"] = "Pending";
|
strings_["pending"] = "Pending";
|
||||||
|
strings_["tx_dropped"] = "Dropped";
|
||||||
|
strings_["tx_stale_pending"] = "Expired?";
|
||||||
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
||||||
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
||||||
strings_["sent"] = "sent";
|
strings_["sent"] = "sent";
|
||||||
@@ -1580,6 +1617,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_mining_hs"] = "%.1f H/s";
|
strings_["sb_mining_hs"] = "%.1f H/s";
|
||||||
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
||||||
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
||||||
|
strings_["sb_lite_sync_error"] = "Sync error: %.80s";
|
||||||
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
||||||
strings_["sb_rescanning"] = "Rescanning";
|
strings_["sb_rescanning"] = "Rescanning";
|
||||||
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
||||||
@@ -1613,6 +1651,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["node_banner_open_config"] = "Open config folder";
|
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_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_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
|
||||||
|
strings_["node_banner_legacy_title"] = "Your transparent funds can't be moved yet";
|
||||||
|
strings_["node_banner_legacy_detail"] = "This wallet has no usable seed, so it can't create a shielded address to move its transparent funds. Recover them into a fresh seed-backed wallet.";
|
||||||
|
strings_["node_banner_recover"] = "Recover funds";
|
||||||
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
||||||
strings_["data_stale_prefix"] = "Updated";
|
strings_["data_stale_prefix"] = "Updated";
|
||||||
strings_["data_stale_tooltip"] =
|
strings_["data_stale_tooltip"] =
|
||||||
@@ -2016,6 +2057,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["console_clear"] = "Clear";
|
strings_["console_clear"] = "Clear";
|
||||||
strings_["console_clear_console"] = "Clear Console";
|
strings_["console_clear_console"] = "Clear Console";
|
||||||
strings_["console_cleared"] = "Console cleared";
|
strings_["console_cleared"] = "Console cleared";
|
||||||
|
strings_["console_secret_result_hidden"] = "[result hidden — contains private key material. Use \"Copy secret\" to copy it, or it clears automatically.]";
|
||||||
|
strings_["console_secret_result_expired"] = "[hidden secret result cleared]";
|
||||||
|
strings_["console_copy_secret"] = "Copy secret";
|
||||||
|
strings_["console_copy_secret_tip"] = "Copy the hidden key/seed result to the clipboard (auto-clears after ~45s).";
|
||||||
|
strings_["console_hide_secret"] = "Hide";
|
||||||
strings_["console_click_commands"] = "Click commands above to insert them";
|
strings_["console_click_commands"] = "Click commands above to insert them";
|
||||||
strings_["console_click_insert"] = "Click to insert";
|
strings_["console_click_insert"] = "Click to insert";
|
||||||
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
||||||
@@ -2131,6 +2177,16 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["export_tx_none"] = "No transactions to export";
|
strings_["export_tx_none"] = "No transactions to export";
|
||||||
strings_["export_tx_success"] = "Transactions exported successfully";
|
strings_["export_tx_success"] = "Transactions exported successfully";
|
||||||
strings_["export_tx_open_folder"] = "Open folder";
|
strings_["export_tx_open_folder"] = "Open folder";
|
||||||
|
strings_["export_tx_scope"] = "Export scope";
|
||||||
|
strings_["export_tx_scope_all"] = "All transactions";
|
||||||
|
strings_["export_tx_scope_filter"] = "Current History filter";
|
||||||
|
strings_["export_tx_scope_range"] = "Date range";
|
||||||
|
strings_["export_tx_scope_filter_none"] = "No filter is active on the History tab";
|
||||||
|
strings_["export_tx_scope_stale"] = "The History filter changed while loading. Close and reopen this dialog.";
|
||||||
|
strings_["export_tx_range_from"] = "From (YYYY-MM-DD)";
|
||||||
|
strings_["export_tx_range_to"] = "To (YYYY-MM-DD)";
|
||||||
|
strings_["export_tx_range_invalid"] = "Enter valid dates as YYYY-MM-DD, with From on or before To";
|
||||||
|
strings_["export_tx_range_pending_note"] = "Pending transactions have no date and are not included in a date range.";
|
||||||
strings_["export_tx_title"] = "Export Transactions to CSV";
|
strings_["export_tx_title"] = "Export Transactions to CSV";
|
||||||
|
|
||||||
// --- Import Key Dialog ---
|
// --- Import Key Dialog ---
|
||||||
@@ -2664,10 +2720,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
||||||
strings_["send_tooltip_select_source"] = "Select a source address first";
|
strings_["send_tooltip_select_source"] = "Select a source address first";
|
||||||
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
||||||
|
strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up.";
|
||||||
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
||||||
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
||||||
strings_["send_total"] = "Total";
|
strings_["send_total"] = "Total";
|
||||||
strings_["send_tx_failed"] = "Transaction failed";
|
strings_["send_tx_failed"] = "Transaction failed";
|
||||||
|
strings_["send_maybe_sent"] = "This transaction may already have been broadcast (the reply was lost). Do NOT send again yet - check History first.";
|
||||||
strings_["send_tx_sent"] = "Transaction sent!";
|
strings_["send_tx_sent"] = "Transaction sent!";
|
||||||
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
||||||
strings_["send_tx_success"] = "Transaction sent successfully!";
|
strings_["send_tx_success"] = "Transaction sent successfully!";
|
||||||
@@ -3081,6 +3139,15 @@ const char* I18n::translate(const char* key) const
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string I18n::translateCopy(const char* key) const
|
||||||
|
{
|
||||||
|
if (!key) return std::string();
|
||||||
|
std::shared_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
auto it = strings_.find(key);
|
||||||
|
if (it != strings_.end()) return it->second;
|
||||||
|
return std::string(key);
|
||||||
|
}
|
||||||
|
|
||||||
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
||||||
{
|
{
|
||||||
// Check if already registered
|
// Check if already registered
|
||||||
|
|||||||
+22
-1
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <shared_mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -38,7 +40,16 @@ public:
|
|||||||
* @return Translated string, or key if not found
|
* @return Translated string, or key if not found
|
||||||
*/
|
*/
|
||||||
const char* translate(const char* key) const;
|
const char* translate(const char* key) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translate: returns a COPY under a read lock.
|
||||||
|
*
|
||||||
|
* translate() hands back a pointer into the string table, which loadLanguage() rewrites on the
|
||||||
|
* main thread. Any caller off the main thread (e.g. the EmbeddedDaemon monitor thread) MUST use
|
||||||
|
* this instead; main-thread UI code keeps using translate()/TR().
|
||||||
|
*/
|
||||||
|
std::string translateCopy(const char* key) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get current locale
|
* @brief Get current locale
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +77,9 @@ private:
|
|||||||
void overlayTranslations(const nlohmann::json& translations);
|
void overlayTranslations(const nlohmann::json& translations);
|
||||||
|
|
||||||
std::string current_locale_ = "en";
|
std::string current_locale_ = "en";
|
||||||
|
// Guards strings_/current_locale_ against loadLanguage() (main thread, writer) racing
|
||||||
|
// translateCopy() (off-thread readers). The lock-free translate() is main-thread only.
|
||||||
|
mutable std::shared_mutex strings_mutex_;
|
||||||
std::unordered_map<std::string, std::string> strings_;
|
std::unordered_map<std::string, std::string> strings_;
|
||||||
std::vector<std::pair<std::string, std::string>> available_languages_;
|
std::vector<std::pair<std::string, std::string>> available_languages_;
|
||||||
};
|
};
|
||||||
@@ -79,6 +93,13 @@ inline const char* tr(const char* key) {
|
|||||||
return I18n::instance().translate(key);
|
return I18n::instance().translate(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translation returning an owned copy. Use from any non-main thread.
|
||||||
|
*/
|
||||||
|
inline std::string trCopy(const char* key) {
|
||||||
|
return I18n::instance().translateCopy(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,29 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Strictly parse a decimal DRGX amount from an (attacker-influenceable) payment URI / QR: digits with at
|
||||||
|
// most one '.', at most 8 fractional digits, in [0, 21000000]. Rejects empty, NaN/inf, signs, whitespace
|
||||||
|
// and trailing garbage that std::stod would otherwise accept — so a crafted URI can't inject a bogus
|
||||||
|
// amount into the Send form.
|
||||||
|
bool parseStrictAmount(const std::string& s, double& out) {
|
||||||
|
if (s.empty() || s.size() > 32) return false;
|
||||||
|
int dots = 0, digits = 0, frac = 0; bool afterDot = false;
|
||||||
|
for (char c : s) {
|
||||||
|
if (c == '.') { if (++dots > 1) return false; afterDot = true; }
|
||||||
|
else if (c >= '0' && c <= '9') { ++digits; if (afterDot && ++frac > 8) return false; }
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
if (digits == 0) return false;
|
||||||
|
double v = 0.0;
|
||||||
|
try { v = std::stod(s); } catch (...) { return false; }
|
||||||
|
if (!std::isfinite(v) || v < 0.0 || v > 21000000.0) return false;
|
||||||
|
out = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
||||||
|
|
||||||
if (key == "amount" || key == "amt") {
|
if (key == "amount" || key == "amt") {
|
||||||
try {
|
double a = 0.0;
|
||||||
result.amount = std::stod(value);
|
if (!parseStrictAmount(value, a)) {
|
||||||
} catch (...) {
|
result.error = "Invalid amount";
|
||||||
// Invalid amount, keep as 0
|
result.valid = false;
|
||||||
|
return result; // reject the whole URI rather than silently treating it as 0
|
||||||
}
|
}
|
||||||
|
result.amount = a;
|
||||||
} else if (key == "label") {
|
} else if (key == "label") {
|
||||||
result.label = value;
|
result.label = value;
|
||||||
} else if (key == "memo") {
|
} else if (key == "memo") {
|
||||||
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate amount if present
|
// Validate amount if present
|
||||||
if (result.amount < 0) {
|
if (!std::isfinite(result.amount) || result.amount < 0 || result.amount > 21000000.0) {
|
||||||
result.error = "Invalid negative amount";
|
result.error = "Invalid negative amount";
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -25,6 +25,7 @@
|
|||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
#include <psapi.h>
|
#include <psapi.h>
|
||||||
#include <tlhelp32.h>
|
#include <tlhelp32.h>
|
||||||
|
#include <aclapi.h> // SetEntriesInAcl / EXPLICIT_ACCESS — owner-only DACL for restricted writes
|
||||||
#else
|
#else
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -361,15 +362,54 @@ bool Platform::writeFileAtomically(const std::string& path,
|
|||||||
const std::string tmp = path + ".tmp";
|
const std::string tmp = path + ".tmp";
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
// When restrictPermissions, build a PROTECTED owner-only DACL so the secret file (exported keys,
|
||||||
|
// seed backup, vault, chat export) is readable only by the current user — not whatever the parent
|
||||||
|
// dir's inherited ACL grants. Best-effort: on any failure fall back to default ACLs (the per-user
|
||||||
|
// profile dir still protects it). The ACL is set on the temp file; MoveFileEx is a same-volume
|
||||||
|
// rename, so the explicit (non-inherited) DACL carries to the final path.
|
||||||
|
PACL dacl = nullptr;
|
||||||
|
PTOKEN_USER tokenUser = nullptr;
|
||||||
|
SECURITY_DESCRIPTOR sd;
|
||||||
|
SECURITY_ATTRIBUTES saOwner{};
|
||||||
|
SECURITY_ATTRIBUTES* psa = nullptr;
|
||||||
|
if (restrictPermissions) {
|
||||||
|
HANDLE tok = nullptr;
|
||||||
|
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) {
|
||||||
|
DWORD need = 0;
|
||||||
|
GetTokenInformation(tok, TokenUser, nullptr, 0, &need);
|
||||||
|
if (need > 0) {
|
||||||
|
tokenUser = static_cast<PTOKEN_USER>(LocalAlloc(LPTR, need));
|
||||||
|
if (tokenUser && GetTokenInformation(tok, TokenUser, tokenUser, need, &need)) {
|
||||||
|
EXPLICIT_ACCESSA ea{};
|
||||||
|
ea.grfAccessPermissions = GENERIC_ALL;
|
||||||
|
ea.grfAccessMode = SET_ACCESS;
|
||||||
|
ea.grfInheritance = NO_INHERITANCE;
|
||||||
|
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
|
||||||
|
ea.Trustee.TrusteeType = TRUSTEE_IS_USER;
|
||||||
|
ea.Trustee.ptstrName = static_cast<LPSTR>(tokenUser->User.Sid);
|
||||||
|
if (SetEntriesInAclA(1, &ea, nullptr, &dacl) == ERROR_SUCCESS &&
|
||||||
|
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION) &&
|
||||||
|
SetSecurityDescriptorControl(&sd, SE_DACL_PROTECTED, SE_DACL_PROTECTED) &&
|
||||||
|
SetSecurityDescriptorDacl(&sd, TRUE, dacl, FALSE)) {
|
||||||
|
saOwner.nLength = sizeof(saOwner);
|
||||||
|
saOwner.lpSecurityDescriptor = &sd;
|
||||||
|
saOwner.bInheritHandle = FALSE;
|
||||||
|
psa = &saOwner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseHandle(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
|
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
|
||||||
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
|
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
|
||||||
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
|
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
|
||||||
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
|
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
|
||||||
// AV / the indexer had the new file open.
|
// AV / the indexer had the new file open.
|
||||||
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, nullptr,
|
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, psa,
|
||||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
if (h == INVALID_HANDLE_VALUE) return false;
|
if (h == INVALID_HANDLE_VALUE) { if (dacl) LocalFree(dacl); if (tokenUser) LocalFree(tokenUser); return false; }
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
while (written < content.size()) {
|
while (written < content.size()) {
|
||||||
@@ -382,9 +422,12 @@ bool Platform::writeFileAtomically(const std::string& path,
|
|||||||
// Flush the temp file's contents to stable storage before the rename.
|
// Flush the temp file's contents to stable storage before the rename.
|
||||||
if (ok && !FlushFileBuffers(h)) ok = false;
|
if (ok && !FlushFileBuffers(h)) ok = false;
|
||||||
CloseHandle(h);
|
CloseHandle(h);
|
||||||
|
// The DACL is baked into the temp file's security descriptor at CreateFileA time, so release the
|
||||||
|
// builders now; every path below (including the early returns) is past this point.
|
||||||
|
if (dacl) LocalFree(dacl);
|
||||||
|
if (tokenUser) LocalFree(tokenUser);
|
||||||
if (!ok) { DeleteFileA(tmp.c_str()); return false; }
|
if (!ok) { DeleteFileA(tmp.c_str()); return false; }
|
||||||
}
|
}
|
||||||
(void)restrictPermissions; // owner-only ACLs rely on the per-user profile dir on Windows
|
|
||||||
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
|
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
|
||||||
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
|
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
|
||||||
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
|
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
||||||
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
||||||
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
||||||
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
|
if (walletFile.empty() || walletFile == "wallet.dat") {
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::string s;
|
std::string s;
|
||||||
s.reserve(walletFile.size());
|
s.reserve(walletFile.size());
|
||||||
for (unsigned char c : walletFile)
|
for (unsigned char c : walletFile)
|
||||||
@@ -58,11 +62,18 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
char suffix[9];
|
char suffix[9];
|
||||||
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
||||||
if (s.size() > 48) s.resize(48);
|
if (s.size() > 48) s.resize(48);
|
||||||
scope_ = s + "-" + suffix;
|
std::string newScope = s + "-" + suffix;
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_ = std::move(newScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SecureVault::getVaultPath() const {
|
std::string SecureVault::getVaultPath() const {
|
||||||
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
|
std::string scope;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope = scope_;
|
||||||
|
}
|
||||||
|
const std::string name = scope.empty() ? std::string("vault.dat") : ("vault-" + scope + ".dat");
|
||||||
return (fs::path(Platform::getConfigDir()) / name).string();
|
return (fs::path(Platform::getConfigDir()) / name).string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -101,6 +102,9 @@ private:
|
|||||||
const uint8_t* salt, size_t saltLen,
|
const uint8_t* salt, size_t saltLen,
|
||||||
uint8_t* key, size_t keyLen) const;
|
uint8_t* key, size_t keyLen) const;
|
||||||
|
|
||||||
|
// Guards scope_: setWalletScope() runs on the main thread (wallet switch) while retrieve()/hasVault()/
|
||||||
|
// changePin() run on RPC worker threads and read it via getVaultPath().
|
||||||
|
mutable std::mutex scopeMutex_;
|
||||||
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
|||||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||||
: api_(other.api_),
|
: api_(other.api_),
|
||||||
unavailableReason_(std::move(other.unavailableReason_)),
|
unavailableReason_(std::move(other.unavailableReason_)),
|
||||||
shutdownCalled_(other.shutdownCalled_),
|
shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
|
||||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||||
{
|
{
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||||
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
|||||||
shutdown();
|
shutdown();
|
||||||
api_ = other.api_;
|
api_ = other.api_;
|
||||||
unavailableReason_ = std::move(other.unavailableReason_);
|
unavailableReason_ = std::move(other.unavailableReason_);
|
||||||
shutdownCalled_ = other.shutdownCalled_;
|
shutdownCalled_.store(other.shutdownCalled_.load(std::memory_order_acquire), std::memory_order_release);
|
||||||
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||||
std::memory_order_release);
|
std::memory_order_release);
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
bool overwrite)
|
bool overwrite)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNewFromPhrase(
|
return takeOwnedString(api_.initializeNewFromPhrase(
|
||||||
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
||||||
}
|
}
|
||||||
@@ -158,6 +160,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,9 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
if (command.empty()) return {false, {}, "lite command is empty"};
|
||||||
|
// A retired controller's bridge is shut down; litelib_execute would run on the NEW global client
|
||||||
|
// (a stale send/save/sync/console command). Refuse it here, once, for every detached thread.
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||||
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
|
|
||||||
void LiteClientBridge::shutdown()
|
void LiteClientBridge::shutdown()
|
||||||
{
|
{
|
||||||
if (shutdownCalled_) return;
|
if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
|
||||||
shutdownCalled_ = true;
|
|
||||||
if (available()) api_.shutdown();
|
if (available()) api_.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiteBridgeStringResult LiteClientBridge::shutDownResult()
|
||||||
|
{
|
||||||
|
return {false, {}, "lite client was shut down"};
|
||||||
|
}
|
||||||
|
|
||||||
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
||||||
{
|
{
|
||||||
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public:
|
|||||||
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
||||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||||
void shutdown();
|
void shutdown();
|
||||||
|
// True once shutdown() ran (retire() / destruction / move-from). After this every backend call
|
||||||
|
// (execute / initialize*) is refused: litelib_* acts on the process-global client, which may by
|
||||||
|
// then belong to a NEW controller.
|
||||||
|
bool isShutdown() const { return shutdownCalled_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
||||||
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
||||||
@@ -82,12 +86,13 @@ private:
|
|||||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||||
|
|
||||||
LiteBridgeStringResult unavailableResult() const;
|
LiteBridgeStringResult unavailableResult() const;
|
||||||
|
static LiteBridgeStringResult shutDownResult();
|
||||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||||
static bool commandIsReadOnly(const std::string& command);
|
static bool commandIsReadOnly(const std::string& command);
|
||||||
|
|
||||||
LiteClientBridgeApi api_;
|
LiteClientBridgeApi api_;
|
||||||
std::string unavailableReason_;
|
std::string unavailableReason_;
|
||||||
bool shutdownCalled_ = false;
|
std::atomic<bool> shutdownCalled_{false}; // read by detached threads in execute() while retire() sets it
|
||||||
std::atomic<std::uint64_t> mutationEpoch_{0};
|
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ LiteBridgeOwnedString::LiteBridgeOwnedString(LiteBridgeOwnedString&& other) noex
|
|||||||
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
||||||
{
|
{
|
||||||
if (this == &other) return *this;
|
if (this == &other) return *this;
|
||||||
copyValueBeforeFree();
|
// Free only — do NOT copy the raw value here: nothing reads copiedValue_ after this, and the copy
|
||||||
|
// (std::string assignment) can throw bad_alloc, which from this noexcept path would call std::terminate.
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
moveFrom(std::move(other));
|
moveFrom(std::move(other));
|
||||||
@@ -49,7 +50,8 @@ LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&&
|
|||||||
|
|
||||||
LiteBridgeOwnedString::~LiteBridgeOwnedString()
|
LiteBridgeOwnedString::~LiteBridgeOwnedString()
|
||||||
{
|
{
|
||||||
copyValueBeforeFree();
|
// Free only (see operator= note): the copy is unnecessary when the object is dying and must never
|
||||||
|
// throw out of a destructor. The copy happens solely in intoResult(), the one path that reads it.
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
}
|
}
|
||||||
@@ -66,13 +68,16 @@ LiteBridgeStringResult LiteBridgeOwnedString::intoResult()
|
|||||||
return {false, {}, "lite bridge freeString function is unavailable"};
|
return {false, {}, "lite bridge freeString function is unavailable"};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyValueBeforeFree() can throw bad_alloc; if it does, rawValue_ is still set and the destructor
|
||||||
|
// (now free-only) releases it — no leak, no re-copy, no terminate.
|
||||||
copyValueBeforeFree();
|
copyValueBeforeFree();
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
|
|
||||||
const std::string resultValue = copiedValue_;
|
// Move the copy into the result instead of making a second const copy that would linger unwiped.
|
||||||
|
std::string resultValue = std::move(copiedValue_);
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
||||||
return {true, resultValue, {}};
|
return {true, std::move(resultValue), {}};
|
||||||
}
|
}
|
||||||
|
|
||||||
void LiteBridgeOwnedString::copyValueBeforeFree()
|
void LiteBridgeOwnedString::copyValueBeforeFree()
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 21,000,000 DRGX in zatoshi: no single backend-reported amount can legitimately exceed the supply.
|
||||||
|
constexpr std::int64_t kMaxMoneyZat = static_cast<std::int64_t>(21000000) * 100000000LL;
|
||||||
|
|
||||||
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +98,9 @@ bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
|||||||
if (value.is_string()) {
|
if (value.is_string()) {
|
||||||
const auto text = value.get<std::string>();
|
const auto text = value.get<std::string>();
|
||||||
if (text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
|
// stoull follows strtoull: it accepts a leading '-' (wrapping to a huge value), '+' and
|
||||||
|
// whitespace. Require a leading digit so a hostile/buggy backend can't forge a balance.
|
||||||
|
if (!std::isdigit(static_cast<unsigned char>(text[0]))) return false;
|
||||||
std::size_t parsedSize = 0;
|
std::size_t parsedSize = 0;
|
||||||
const auto parsed = std::stoull(text, &parsedSize, 10);
|
const auto parsed = std::stoull(text, &parsedSize, 10);
|
||||||
if (parsedSize != text.size()) return false;
|
if (parsedSize != text.size()) return false;
|
||||||
@@ -125,6 +131,8 @@ bool jsonToSigned(const json& value, std::int64_t& output)
|
|||||||
if (value.is_string()) {
|
if (value.is_string()) {
|
||||||
const auto text = value.get<std::string>();
|
const auto text = value.get<std::string>();
|
||||||
if (text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
|
// Reject leading whitespace / '+' that strtoll would otherwise accept (a '-' is valid here).
|
||||||
|
if (text[0] != '-' && !std::isdigit(static_cast<unsigned char>(text[0]))) return false;
|
||||||
std::size_t parsedSize = 0;
|
std::size_t parsedSize = 0;
|
||||||
const auto parsed = std::stoll(text, &parsedSize, 10);
|
const auto parsed = std::stoll(text, &parsedSize, 10);
|
||||||
if (parsedSize != text.size()) return false;
|
if (parsedSize != text.size()) return false;
|
||||||
@@ -277,9 +285,20 @@ bool parseSpendableOutput(const json& value,
|
|||||||
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
||||||
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
||||||
|
if (output.value > static_cast<std::uint64_t>(kMaxMoneyZat)) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".value", "note value exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
||||||
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
||||||
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
||||||
|
// The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor
|
||||||
|
// height) is authoritative when present (Sapling notes only; transparent utxos carry no such field,
|
||||||
|
// so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability.
|
||||||
|
bool backendSpendable = true;
|
||||||
|
if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false;
|
||||||
|
if (value.contains("spendable") && value.at("spendable").is_boolean()) {
|
||||||
|
output.spendable = output.spendable && backendSpendable;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,15 +356,37 @@ bool parseTransactionRecord(const json& value,
|
|||||||
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
||||||
LiteTransactionOutput parsedOutput;
|
LiteTransactionOutput parsedOutput;
|
||||||
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
||||||
|
// Reject negative values and guard the running sum: signed overflow is UB, and a hostile
|
||||||
|
// backend could otherwise overflow the total into a bogus (even negative) amount.
|
||||||
|
const std::string outputPath = path + ".outgoing_metadata[" + std::to_string(index) + "]";
|
||||||
|
if (parsedOutput.value < 0 || parsedOutput.value > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, outputPath + ".value", "outgoing output value is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
|
if (output.amount > kMaxMoneyZat - parsedOutput.value) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".outgoing_metadata", "outgoing output total exceeds the maximum supply");
|
||||||
|
}
|
||||||
output.amount += parsedOutput.value;
|
output.amount += parsedOutput.value;
|
||||||
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
||||||
}
|
}
|
||||||
|
// A send this wallet did not author (seed restore / other device) has empty outgoing_metadata,
|
||||||
|
// so the sum is 0. Fall back to the record's net 'amount' magnitude (includes the fee) so Sent
|
||||||
|
// totals aren't understated. Display-only; tolerant of a missing/odd field.
|
||||||
|
if (output.amount == 0 && value.contains("amount") && !value.at("amount").is_null()) {
|
||||||
|
std::int64_t net = 0;
|
||||||
|
if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits<std::int64_t>::min()
|
||||||
|
&& (net < 0 ? -net : net) <= kMaxMoneyZat) {
|
||||||
|
output.amount = net < 0 ? -net : net;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
output.direction = LiteTransactionDirection::Receive;
|
output.direction = LiteTransactionDirection::Receive;
|
||||||
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
||||||
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
||||||
|
if (output.amount < 0 || output.amount > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".amount", "received amount is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -561,11 +602,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
|||||||
|
|
||||||
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
||||||
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
||||||
std::string syncing = "false";
|
std::optional<std::string> syncingField;
|
||||||
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
|
if (!readOptionalStringField(value, "syncing", syncingField, result)) return result;
|
||||||
|
const std::string syncing = syncingField.value_or(std::string());
|
||||||
const bool isSyncing = (syncing == "true" || syncing == "1");
|
const bool isSyncing = (syncing == "true" || syncing == "1");
|
||||||
|
const bool isIdle = (syncing == "false" || syncing == "0");
|
||||||
|
|
||||||
if (isSyncing) {
|
if (!isSyncing && !isIdle) {
|
||||||
|
// Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced.
|
||||||
|
result.syncStatus.syncedBlocks = 0;
|
||||||
|
result.syncStatus.totalBlocks = 0;
|
||||||
|
result.syncStatus.complete = false;
|
||||||
|
result.syncStatus.indeterminate = true;
|
||||||
|
result.syncStatus.progress = 0.0;
|
||||||
|
} else if (isSyncing) {
|
||||||
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
||||||
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
||||||
result.syncStatus.complete = false; // actively syncing
|
result.syncStatus.complete = false; // actively syncing
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
|
|||||||
std::uint64_t totalBlocks = 0;
|
std::uint64_t totalBlocks = 0;
|
||||||
double progress = 0.0;
|
double progress = 0.0;
|
||||||
bool complete = false;
|
bool complete = false;
|
||||||
|
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
|
||||||
|
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
|
||||||
|
// the save proceeds as it always did.
|
||||||
|
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
|
||||||
|
{
|
||||||
|
const auto r = bridge.execute("encryptionstatus", "");
|
||||||
|
if (!r.ok) return false;
|
||||||
|
try {
|
||||||
|
const auto j = nlohmann::json::parse(r.value);
|
||||||
|
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
||||||
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
||||||
// every attempt. Captures shared state only.
|
// every attempt. Captures shared state only.
|
||||||
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
|
|||||||
op.error = call.error.empty() ? "sync failed" : call.error;
|
op.error = call.error.empty() ? "sync failed" : call.error;
|
||||||
}
|
}
|
||||||
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
||||||
if (!bridge->execute("save", "").ok) // W5-2
|
// Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
|
||||||
|
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
|
||||||
|
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
|
||||||
|
if (liteWalletEncryptedAndUnlocked(*bridge))
|
||||||
|
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
|
||||||
|
else if (!bridge->execute("save", "").ok) // W5-2
|
||||||
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
||||||
if (op.ok) {
|
if (op.ok) {
|
||||||
scan->setError(std::string());
|
scan->setError(std::string());
|
||||||
@@ -358,13 +378,38 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
||||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
||||||
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
||||||
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
|
// Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
|
||||||
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
|
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
|
||||||
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
|
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
|
||||||
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
|
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
|
||||||
state.spendablePrivateBalance = state.privateBalance;
|
{
|
||||||
state.spendableTransparentBalance = state.transparentBalance;
|
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
|
||||||
state.spendableTotalBalance = state.totalBalance;
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis);
|
||||||
|
state.unconfirmedBalance = static_cast<double>(
|
||||||
|
std::max(model.balance.unconfirmedZatoshis, notYetSpendable)) / kZatoshisPerCoin;
|
||||||
|
}
|
||||||
|
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
|
||||||
|
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
|
||||||
|
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
|
||||||
|
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
|
||||||
|
const double spendableShielded = static_cast<double>(
|
||||||
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
||||||
|
state.spendablePrivateBalance = spendableShielded;
|
||||||
|
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
|
||||||
|
// an unconfirmed tx of ours is still in transparentBalance. Subtract those from the SPENDABLE figure only
|
||||||
|
// (clamped; gross transparentBalance display unchanged). Needs this cycle's utxo list: if the notes call
|
||||||
|
// failed, nothing is subtracted (same as before).
|
||||||
|
std::uint64_t unconfirmedSpentTransparent = 0;
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
for (const auto& output : model.spendableOutputs) {
|
||||||
|
if (output.kind == LiteSpendableOutputKind::Utxo && output.unconfirmedSpent && !output.spent)
|
||||||
|
unconfirmedSpentTransparent += output.valueZatoshis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::uint64_t spendableTransparentZat = model.balance.transparentZatoshis -
|
||||||
|
std::min(unconfirmedSpentTransparent, model.balance.transparentZatoshis);
|
||||||
|
state.spendableTransparentBalance = static_cast<double>(spendableTransparentZat) / kZatoshisPerCoin;
|
||||||
|
state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.hasAddresses) {
|
if (model.hasAddresses) {
|
||||||
@@ -373,10 +418,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
||||||
// the per-address figures match the confirmed balance the wallet treats as available.
|
// the per-address figures match the confirmed balance the wallet treats as available.
|
||||||
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
||||||
|
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
||||||
if (model.hasSpendableOutputs) {
|
if (model.hasSpendableOutputs) {
|
||||||
for (const auto& output : model.spendableOutputs) {
|
for (const auto& output : model.spendableOutputs) {
|
||||||
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
||||||
perAddressZatoshis[output.address] += output.valueZatoshis;
|
perAddressZatoshis[output.address] += output.valueZatoshis;
|
||||||
|
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,8 +432,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
||||||
// like fund loss and breaks "send from this address".
|
// like fund loss and breaks "send from this address".
|
||||||
std::unordered_map<std::string, double> priorBalances;
|
std::unordered_map<std::string, double> priorBalances;
|
||||||
|
std::unordered_map<std::string, double> priorSpendable;
|
||||||
|
std::unordered_map<std::string, bool> priorHasKey;
|
||||||
if (!model.hasSpendableOutputs) {
|
if (!model.hasSpendableOutputs) {
|
||||||
for (const auto& a : state.addresses) priorBalances[a.address] = a.balance;
|
for (const auto& a : state.addresses) {
|
||||||
|
priorBalances[a.address] = a.balance;
|
||||||
|
priorSpendable[a.address] = a.spendableBalance;
|
||||||
|
priorHasKey[a.address] = a.has_spending_key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.addresses.clear();
|
state.addresses.clear();
|
||||||
@@ -404,9 +457,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
||||||
}
|
}
|
||||||
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
|
// balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
|
||||||
|
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
const auto sit = perAddressSpendableZatoshis.find(addr.address);
|
||||||
|
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
|
||||||
|
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
|
||||||
|
} else {
|
||||||
|
const auto psit = priorSpendable.find(addr.address);
|
||||||
|
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
|
||||||
|
}
|
||||||
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
||||||
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
||||||
|
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
|
||||||
|
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
|
||||||
|
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
|
||||||
|
}
|
||||||
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
||||||
state.z_addresses.push_back(info);
|
state.z_addresses.push_back(info);
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +498,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
||||||
}
|
}
|
||||||
tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin;
|
// Signed like the full node: a send is a negative debit, a receive positive. Consumers
|
||||||
|
// (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs.
|
||||||
|
tx.amount = static_cast<double>(record.signedAmountZatoshis) / kZatoshisPerCoin;
|
||||||
tx.timestamp = record.timestamp;
|
tx.timestamp = record.timestamp;
|
||||||
tx.address = record.address;
|
tx.address = record.address;
|
||||||
tx.memo = record.memo;
|
tx.memo = record.memo;
|
||||||
@@ -912,9 +980,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
|
|||||||
if (op == PendingSecurityOp::Encrypt) {
|
if (op == PendingSecurityOp::Encrypt) {
|
||||||
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||||
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
||||||
if (!enc.ok)
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
|
||||||
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
||||||
enc.error);
|
enc.error);
|
||||||
|
}
|
||||||
} else if (op == PendingSecurityOp::Unlock) {
|
} else if (op == PendingSecurityOp::Unlock) {
|
||||||
if (!unlockWallet(pendingLifecyclePassphrase_))
|
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||||
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||||
@@ -1044,7 +1114,12 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
||||||
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
||||||
// surface it as the synced height/tip so the block count is correct at rest.
|
// surface it as the synced height/tip so the block count is correct at rest.
|
||||||
if (bridge_ && (!honorStop || running_.load())) {
|
// Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
|
||||||
|
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
|
||||||
|
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
|
||||||
|
(syncResult.syncStatus.indeterminate ||
|
||||||
|
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
|
||||||
|
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
|
||||||
const auto h = bridge_->execute("height", "");
|
const auto h = bridge_->execute("height", "");
|
||||||
if (h.ok) {
|
if (h.ok) {
|
||||||
const auto parsed = parseLiteHeightResponse(h.value);
|
const auto parsed = parseLiteHeightResponse(h.value);
|
||||||
@@ -1068,7 +1143,14 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
model.sync.progress = std::min(model.sync.progress, 0.999);
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
|
||||||
|
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
|
||||||
|
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
|
||||||
|
model.sync.complete = false;
|
||||||
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
|
}
|
||||||
model.syncError = scan_->errorCopy();
|
model.syncError = scan_->errorCopy();
|
||||||
|
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1317,14 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
|
|||||||
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse
|
||||||
|
// copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable).
|
||||||
|
static void wipeJsonStrings(nlohmann::json& j)
|
||||||
|
{
|
||||||
|
if (j.is_string()) { secureWipeLiteSecret(j.get_ref<nlohmann::json::string_t&>()); }
|
||||||
|
else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); }
|
||||||
|
}
|
||||||
|
|
||||||
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
||||||
{
|
{
|
||||||
LiteExportResult out;
|
LiteExportResult out;
|
||||||
@@ -1243,13 +1333,18 @@ LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& opti
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
// Empty address -> export keys for all addresses; otherwise just the given address.
|
// Empty address -> export keys for all addresses; otherwise just the given address.
|
||||||
const auto result = bridge_->execute("export", optionalAddress);
|
auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
out.error = result.error.empty() ? "export failed" : result.error;
|
out.error = result.error.empty() ? "export failed" : result.error;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
nlohmann::json j;
|
||||||
|
// result.value + the parse tree hold the private keys; wipe them on every return path. out already
|
||||||
|
// has its own copy (out.privateKeysJson) which the caller wipes after use.
|
||||||
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
||||||
|
scrub{result.value, j};
|
||||||
try {
|
try {
|
||||||
const auto j = nlohmann::json::parse(result.value);
|
j = nlohmann::json::parse(result.value);
|
||||||
if (j.is_object() && j.contains("error")) {
|
if (j.is_object() && j.contains("error")) {
|
||||||
out.error = extractJsonError(j);
|
out.error = extractJsonError(j);
|
||||||
return out;
|
return out;
|
||||||
@@ -1270,13 +1365,18 @@ LiteSeedResult LiteWalletController::exportSeed()
|
|||||||
out.error = "no wallet is open";
|
out.error = "no wallet is open";
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
const auto result = bridge_->execute("seed", "");
|
auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
out.error = result.error.empty() ? "seed export failed" : result.error;
|
out.error = result.error.empty() ? "seed export failed" : result.error;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
nlohmann::json j;
|
||||||
|
// result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase
|
||||||
|
// is extracted separately and returned to the caller).
|
||||||
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
||||||
|
scrub{result.value, j};
|
||||||
try {
|
try {
|
||||||
const auto j = nlohmann::json::parse(result.value);
|
j = nlohmann::json::parse(result.value);
|
||||||
if (j.is_object()) {
|
if (j.is_object()) {
|
||||||
if (j.contains("error")) {
|
if (j.contains("error")) {
|
||||||
out.error = extractJsonError(j);
|
out.error = extractJsonError(j);
|
||||||
@@ -1541,6 +1641,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LiteWalletController::takeEncryptionFailureNotice()
|
||||||
|
{
|
||||||
|
const bool failed = lifecycleEncryptFailed_;
|
||||||
|
lifecycleEncryptFailed_ = false;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
bool LiteWalletController::lockWallet()
|
bool LiteWalletController::lockWallet()
|
||||||
{
|
{
|
||||||
if (!walletOpen_.load() || !bridge_) return false;
|
if (!walletOpen_.load() || !bridge_) return false;
|
||||||
@@ -1589,6 +1696,7 @@ void LiteWalletController::workerLoop()
|
|||||||
{
|
{
|
||||||
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
||||||
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
||||||
|
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
|
||||||
while (running_.load()) {
|
while (running_.load()) {
|
||||||
if (walletOpen_.load()) {
|
if (walletOpen_.load()) {
|
||||||
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
||||||
@@ -1600,6 +1708,19 @@ void LiteWalletController::workerLoop()
|
|||||||
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
||||||
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
||||||
failStreak += 1;
|
failStreak += 1;
|
||||||
|
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
|
||||||
|
// server was gone. Re-publish it demoted, with the reason.
|
||||||
|
if (lastModel && running_.load()) {
|
||||||
|
auto demoted = *lastModel;
|
||||||
|
if (demoted.hasSyncStatus) {
|
||||||
|
demoted.sync.complete = false;
|
||||||
|
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
|
||||||
|
}
|
||||||
|
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
|
||||||
|
lastModel = demoted;
|
||||||
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
|
pendingModel_ = std::move(demoted);
|
||||||
|
}
|
||||||
} else { // DoFull
|
} else { // DoFull
|
||||||
// Sample the change baselines BEFORE the data commands (race rule).
|
// Sample the change baselines BEFORE the data commands (race rule).
|
||||||
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
||||||
@@ -1617,6 +1738,7 @@ void LiteWalletController::workerLoop()
|
|||||||
gate.encrypted = m.encrypted;
|
gate.encrypted = m.encrypted;
|
||||||
gate.locked = m.locked;
|
gate.locked = m.locked;
|
||||||
gate.fullAt = std::chrono::steady_clock::now();
|
gate.fullAt = std::chrono::steady_clock::now();
|
||||||
|
lastModel = m;
|
||||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
pendingModel_ = std::move(model);
|
pendingModel_ = std::move(model);
|
||||||
} else {
|
} else {
|
||||||
@@ -1696,7 +1818,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
|
|||||||
// copy and wipes it.
|
// copy and wipes it.
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet created but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
return result;
|
return result;
|
||||||
@@ -1726,7 +1851,10 @@ LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreR
|
|||||||
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet restored but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.seedPhrase);
|
secureWipeLiteSecret(request.seedPhrase);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ public:
|
|||||||
bool lockWallet();
|
bool lockWallet();
|
||||||
LiteEncryptionResult decryptWallet(std::string passphrase);
|
LiteEncryptionResult decryptWallet(std::string passphrase);
|
||||||
LiteEncryptionStatus encryptionStatus();
|
LiteEncryptionStatus encryptionStatus();
|
||||||
|
// True once (then cleared) if the passphrase encryption of a just-created/restored wallet FAILED, leaving it
|
||||||
|
// unencrypted. Main thread; the App turns it into a visible error instead of a log line.
|
||||||
|
bool takeEncryptionFailureNotice();
|
||||||
|
|
||||||
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
||||||
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
||||||
@@ -488,6 +491,7 @@ private:
|
|||||||
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||||
|
bool lifecycleEncryptFailed_ = false; // main-thread only; see takeEncryptionFailureNotice()
|
||||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||||
|
|||||||
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
|
|||||||
result.model.sync.complete = bundle.syncStatus.complete;
|
result.model.sync.complete = bundle.syncStatus.complete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-address spending-key evidence from the backend's per-note 'spendable' flag. Only POSITIVE evidence
|
||||||
|
// sets spendabilityKnown: a spendable note proves a key; a confirmed, long-mature (well past any anchor
|
||||||
|
// offset) unspent note that is still not spendable can only mean a view-only address. Fresh/immature
|
||||||
|
// notes, no notes, or no chain height stay unknown (the controller then keeps its spendable default).
|
||||||
|
if (result.model.hasAddresses && result.model.hasSpendableOutputs) {
|
||||||
|
constexpr std::int64_t kMatureDepth = 100;
|
||||||
|
const std::int64_t tip = result.model.hasSyncStatus
|
||||||
|
? static_cast<std::int64_t>(result.model.sync.chainHeight) : 0;
|
||||||
|
for (auto& addr : result.model.addresses) {
|
||||||
|
if (addr.kind != LiteWalletAppAddressKind::Shielded) continue;
|
||||||
|
bool anySpendable = false;
|
||||||
|
bool matureNonSpendable = false;
|
||||||
|
for (const auto& out : result.model.spendableOutputs) {
|
||||||
|
if (out.address != addr.address || out.kind != LiteSpendableOutputKind::UnspentNote) continue;
|
||||||
|
if (out.spent || out.unconfirmedSpent || out.pending) continue;
|
||||||
|
if (out.spendable) { anySpendable = true; break; }
|
||||||
|
if (tip > 0 && out.createdInBlock && *out.createdInBlock > 0 &&
|
||||||
|
tip - *out.createdInBlock >= kMatureDepth) {
|
||||||
|
matureNonSpendable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anySpendable) { addr.spendabilityKnown = true; addr.spendable = true; }
|
||||||
|
else if (matureNonSpendable) { addr.spendabilityKnown = true; addr.spendable = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!modelHasAnyMappedField(result.model)) {
|
if (!modelHasAnyMappedField(result.model)) {
|
||||||
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
||||||
result.error = result.issues.back().message;
|
result.error = result.issues.back().message;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a
|
|||||||
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
||||||
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
||||||
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
||||||
|
inline std::atomic<bool> g_liteFakeEncryptFails{false}; // "encrypt" returns an error and changes no state
|
||||||
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
||||||
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
||||||
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
||||||
@@ -261,6 +262,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
|||||||
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
||||||
if (std::strcmp(c, "encrypt") == 0) {
|
if (std::strcmp(c, "encrypt") == 0) {
|
||||||
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
||||||
|
if (g_liteFakeEncryptFails.load())
|
||||||
|
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
|
||||||
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
||||||
return liteFakeDup("{\"result\":\"success\"}");
|
return liteFakeDup("{\"result\":\"success\"}");
|
||||||
}
|
}
|
||||||
|
|||||||
+572
-6
@@ -819,6 +819,41 @@ void testClassifyRpcFailure()
|
|||||||
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
|
EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lost-reply sends: only a transport failure AFTER the request left (no HTTP status) is ambiguous.
|
||||||
|
void testSendMaybeInFlight()
|
||||||
|
{
|
||||||
|
using namespace dragonx::rpc;
|
||||||
|
using K = RpcFailureKind;
|
||||||
|
// Ambiguous: reply timeout, connection cut mid-reply, our own abort - request sent, no HTTP status.
|
||||||
|
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::ReplyTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, true)));
|
||||||
|
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Other, "t", CURLE_GOT_NOTHING, 0, true)));
|
||||||
|
EXPECT_TRUE(sendReplyMayBeLost(RpcTransportError(K::Aborted, "t", CURLE_ABORTED_BY_CALLBACK, 0, true)));
|
||||||
|
// Definitive: never reached the daemon, or the daemon/HTTP layer answered.
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ConnectTimeout, "t", CURLE_OPERATION_TIMEDOUT, 0, false)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::NotListening, "t", CURLE_COULDNT_CONNECT, 0, false)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::QueueFull, "t", CURLE_OK, 500, true)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::ShuttingDown, "t", CURLE_OK, 503, true)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Auth, "t", CURLE_OK, 401, true)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcTransportError(K::Deferred, "t", CURLE_OK, 0, false)));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-6, "Insufficient funds")));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(RpcError(-5, "Invalid address")));
|
||||||
|
EXPECT_FALSE(sendReplyMayBeLost(std::runtime_error("plain")));
|
||||||
|
// Lite text heuristic.
|
||||||
|
EXPECT_TRUE(liteSendErrorMayBeInFlight("status: Unknown, message: \"transport error\""));
|
||||||
|
EXPECT_TRUE(liteSendErrorMayBeInFlight("operation timed out"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("error trying to connect: tcp connect error"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("Error: SendResponse { error_code: 1, error_message: \"bad-txns\" }"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("Insufficient verified funds"));
|
||||||
|
EXPECT_FALSE(liteSendErrorMayBeInFlight("Wallet is locked"));
|
||||||
|
// Marker round trip + idempotence.
|
||||||
|
const std::string marked = markSendMaybeInFlight("RPC request failed: Timeout");
|
||||||
|
EXPECT_TRUE(isSendMaybeInFlight(marked));
|
||||||
|
EXPECT_FALSE(isSendMaybeInFlight("RPC request failed: Timeout"));
|
||||||
|
EXPECT_EQ(markSendMaybeInFlight(marked), marked);
|
||||||
|
EXPECT_EQ(stripSendMaybeInFlight(marked), std::string("RPC request failed: Timeout"));
|
||||||
|
EXPECT_EQ(stripSendMaybeInFlight("plain"), std::string("plain"));
|
||||||
|
}
|
||||||
|
|
||||||
void testSecureWipeAndRemove()
|
void testSecureWipeAndRemove()
|
||||||
{
|
{
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
@@ -1033,7 +1068,17 @@ void testPaymentUri()
|
|||||||
|
|
||||||
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
|
||||||
EXPECT_FALSE(invalid.valid);
|
EXPECT_FALSE(invalid.valid);
|
||||||
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
EXPECT_EQ(invalid.error, std::string("Invalid amount")); // strict parse rejects '-' up front now
|
||||||
|
|
||||||
|
// Crafted amounts that std::stod would have accepted are now rejected before the Send form.
|
||||||
|
for (const char* bad : {"nan", "inf", "1e3", "0x10", "1.5abc", "0.000000001", "21000001", ""}) {
|
||||||
|
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + bad);
|
||||||
|
EXPECT_FALSE(r.valid);
|
||||||
|
}
|
||||||
|
for (const char* ok : {"1", "0.00000001", "21000000", "1.5"}) {
|
||||||
|
auto r = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=" + ok);
|
||||||
|
EXPECT_TRUE(r.valid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void testWalletFileProbe()
|
void testWalletFileProbe()
|
||||||
@@ -1198,10 +1243,35 @@ void testWalletFileProbe()
|
|||||||
|
|
||||||
void testAmountFormatting()
|
void testAmountFormatting()
|
||||||
{
|
{
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
using namespace dragonx::util;
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
|
EXPECT_EQ(formatAmountFixed(1.0), std::string("1.00000000"));
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
|
EXPECT_EQ(formatAmountFixed(0.1), std::string("0.10000000"));
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
|
EXPECT_EQ(formatAmountFixed(0.000000019), std::string("0.00000002"));
|
||||||
|
EXPECT_EQ(formatAmountFixed(12.3456, 2), std::string("12.35"));
|
||||||
|
// No scientific notation / comma decimals for tiny or large amounts (the z_sendmany string).
|
||||||
|
EXPECT_EQ(formatAmountFixed(1e-8), std::string("0.00000001"));
|
||||||
|
EXPECT_EQ(formatAmountFixed(21000000.0), std::string("21000000.00000000"));
|
||||||
|
EXPECT_TRUE(formatAmountFixed(0.00000001).find('e') == std::string::npos);
|
||||||
|
|
||||||
|
// toSats / canAfford — the exact-max send that double addition wrongly blocked (fund-correctness audit).
|
||||||
|
EXPECT_EQ(toSats(63.0410039), 6304100390LL);
|
||||||
|
EXPECT_TRUE(canAfford(63.0409039, 0.0001, 63.0410039)); // true total == available; was false in doubles
|
||||||
|
EXPECT_TRUE(canAfford(44.83756352, 0.0001, 44.83766352));
|
||||||
|
EXPECT_TRUE(canAfford(0.4999, 0.0001, 0.5));
|
||||||
|
EXPECT_TRUE(canAfford(0.5, 0.0, 0.5)); // exact, no fee
|
||||||
|
EXPECT_TRUE(!canAfford(0.5, 0.0001, 0.5)); // amount+fee over available
|
||||||
|
EXPECT_TRUE(!canAfford(0.50001, 0.0, 0.5));
|
||||||
|
|
||||||
|
// coinsToZatoshis — rejects the values that would wrap/round wrong on the lite send path.
|
||||||
|
std::uint64_t z = 0;
|
||||||
|
EXPECT_TRUE(coinsToZatoshis(1.0, z) && z == 100000000ULL);
|
||||||
|
EXPECT_TRUE(coinsToZatoshis(0.00000001, z) && z == 1ULL);
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(0.0, z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(-1.0, z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(0.000000004, z)); // sub-zatoshi rounds to 0 → rejected
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits<double>::quiet_NaN(), z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits<double>::infinity(), z));
|
||||||
|
EXPECT_TRUE(!coinsToZatoshis(21000001.0, z)); // over supply cap
|
||||||
}
|
}
|
||||||
|
|
||||||
void testSpendableFiltering()
|
void testSpendableFiltering()
|
||||||
@@ -2940,6 +3010,88 @@ void testNetworkRefreshResultModels()
|
|||||||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||||||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||||||
EXPECT_EQ(confirmedBlock, 222);
|
EXPECT_EQ(confirmedBlock, 222);
|
||||||
|
|
||||||
|
// Tier-B: per-address balances accumulate in integer satoshis (no IEEE drift over many notes).
|
||||||
|
{
|
||||||
|
std::vector<dragonx::AddressInfo> addrs(1);
|
||||||
|
addrs[0].address = "zs1drift";
|
||||||
|
json notes = json::array();
|
||||||
|
for (int i = 0; i < 10; ++i)
|
||||||
|
notes.push_back(json{{"address", "zs1drift"}, {"amount", 0.1}, {"confirmations", 3}});
|
||||||
|
Refresh::applyShieldedBalancesFromUnspent(addrs, notes);
|
||||||
|
EXPECT_TRUE(addrs[0].balance == 1.0); // exact; double summation would give 0.9999999999999999
|
||||||
|
EXPECT_TRUE(addrs[0].spendableBalance == 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier-B: readBalanceString rejects anything but a plain decimal (std::stod accepted trailing garbage).
|
||||||
|
{
|
||||||
|
auto bad = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", "1.5abc"}, {"transparent", ""}, {"total", "nan"}}, json(), true, json::object(), false);
|
||||||
|
EXPECT_TRUE(!bad.shieldedBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad.transparentBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad.totalBalance.has_value());
|
||||||
|
auto bad2 = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", " 1.0"}, {"transparent", "0x10"}, {"total", "1e3"}}, json(), true, json::object(), false);
|
||||||
|
EXPECT_TRUE(!bad2.shieldedBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad2.transparentBalance.has_value());
|
||||||
|
EXPECT_TRUE(!bad2.totalBalance.has_value());
|
||||||
|
auto good = Refresh::parseCoreRefreshResult(
|
||||||
|
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, json(), true, json::object(), false);
|
||||||
|
EXPECT_NEAR(*good.shieldedBalance, 1.25, 1e-9);
|
||||||
|
EXPECT_NEAR(*good.totalBalance, 1.75, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resilience mediums: the send tip-gate, cached view-tx confirmations advancing, and the dropped /
|
||||||
|
// stale-pending display predicates.
|
||||||
|
void testResilienceMediums()
|
||||||
|
{
|
||||||
|
{ // 1) send tip gate
|
||||||
|
dragonx::SyncInfo s;
|
||||||
|
s.blocks = 100; s.headers = 100; s.syncing = false; s.tip_known = false;
|
||||||
|
EXPECT_FALSE(s.sendTipReady(false)); // peerless / fresh connect: tip unknown
|
||||||
|
EXPECT_TRUE(s.sendTipReady(true)); // lite exempt
|
||||||
|
s.tip_known = true;
|
||||||
|
EXPECT_TRUE(s.sendTipReady(false));
|
||||||
|
s.syncing = true;
|
||||||
|
EXPECT_FALSE(s.sendTipReady(false));
|
||||||
|
s.syncing = false; s.blocks = 0;
|
||||||
|
EXPECT_FALSE(s.sendTipReady(false));
|
||||||
|
}
|
||||||
|
{ // 2) cached view entry confirmations advance (never backwards; pending not extrapolated)
|
||||||
|
using R = dragonx::services::NetworkRefreshService;
|
||||||
|
R::TransactionViewCacheEntry e;
|
||||||
|
e.from_address = "zs-from"; e.confirmations = 3; e.timestamp = 500; e.observed_height = 100;
|
||||||
|
R::TransactionViewCacheEntry::Output o; o.address = "zs-dest"; o.value = 1.0; e.outgoing_outputs.push_back(o);
|
||||||
|
std::vector<dragonx::TransactionInfo> rows;
|
||||||
|
R::appendViewTransactionOutputs(rows, "tx1", e, 105);
|
||||||
|
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(rows[0].confirmations, 8); // 3 + (105-100)
|
||||||
|
std::vector<dragonx::TransactionInfo> rows2;
|
||||||
|
R::appendViewTransactionOutputs(rows2, "tx1", e); // no tip -> unchanged
|
||||||
|
EXPECT_EQ(rows2[0].confirmations, 3);
|
||||||
|
std::vector<dragonx::TransactionInfo> rows3;
|
||||||
|
R::appendViewTransactionOutputs(rows3, "tx1", e, 90); // reorg/older tip: never backwards
|
||||||
|
EXPECT_EQ(rows3[0].confirmations, 3);
|
||||||
|
e.confirmations = 0; // pending entries are not extrapolated
|
||||||
|
std::vector<dragonx::TransactionInfo> rows4;
|
||||||
|
R::appendViewTransactionOutputs(rows4, "tx1", e, 110);
|
||||||
|
EXPECT_EQ(rows4[0].confirmations, 0);
|
||||||
|
}
|
||||||
|
{ // 3) dropped / stale-pending predicates
|
||||||
|
dragonx::TransactionInfo t;
|
||||||
|
t.confirmations = -1;
|
||||||
|
EXPECT_TRUE(t.isConflicted());
|
||||||
|
EXPECT_FALSE(t.isStalePending(100000));
|
||||||
|
t.confirmations = 0; t.timestamp = 1000;
|
||||||
|
EXPECT_FALSE(t.isConflicted());
|
||||||
|
EXPECT_FALSE(t.isStalePending(1000 + 3600)); // exactly the limit: still pending
|
||||||
|
EXPECT_TRUE(t.isStalePending(1000 + 3601));
|
||||||
|
t.timestamp = 0;
|
||||||
|
EXPECT_FALSE(t.isStalePending(999999)); // unknown time never flagged
|
||||||
|
t.timestamp = 1000; t.confirmations = 1;
|
||||||
|
EXPECT_FALSE(t.isStalePending(999999)); // mined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void testOperationStatusPollParsing()
|
void testOperationStatusPollParsing()
|
||||||
@@ -3656,6 +3808,33 @@ void testNodeStatusBanner()
|
|||||||
NodeBannerInputs open; open.lite = true; open.connected = true;
|
NodeBannerInputs open; open.lite = true; open.connected = true;
|
||||||
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
|
EXPECT_TRUE(!evaluateNodeStatusBanner(open).show);
|
||||||
}
|
}
|
||||||
|
// Legacy-funds-stuck: a connected+synced degraded wallet with stuck transparent funds offers recovery.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||||
|
in.legacy_funds_stuck = true;
|
||||||
|
NodeBannerState s = evaluateNodeStatusBanner(in);
|
||||||
|
EXPECT_TRUE(s.show);
|
||||||
|
EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning);
|
||||||
|
EXPECT_TRUE(s.reason == NodeBannerReason::LegacyFundsStuck);
|
||||||
|
EXPECT_TRUE(s.action == NodeBannerAction::RecoverLegacyFunds);
|
||||||
|
}
|
||||||
|
// A functional wallet (legacy_funds_stuck false — e.g. non-mnemonic but can still shield) is NEVER flagged.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
|
// NoPeers takes priority: can't recover before the node can see the chain.
|
||||||
|
{
|
||||||
|
NodeBannerInputs in;
|
||||||
|
in.connected = true; in.tip_known = false; in.blocks = 100; in.legacy_funds_stuck = true;
|
||||||
|
EXPECT_TRUE(evaluateNodeStatusBanner(in).reason == NodeBannerReason::NoPeers);
|
||||||
|
}
|
||||||
|
// Lite never shows it (the lite branch returns first; App also gates the input to full-node).
|
||||||
|
{
|
||||||
|
NodeBannerInputs in; in.lite = true; in.connected = true; in.legacy_funds_stuck = true;
|
||||||
|
EXPECT_TRUE(!evaluateNodeStatusBanner(in).show);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void testStalenessBadge()
|
void testStalenessBadge()
|
||||||
@@ -3776,6 +3955,24 @@ void testSeedMigrationResume()
|
|||||||
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
|
// ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked.
|
||||||
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
|
EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok
|
||||||
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
|
EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked
|
||||||
|
|
||||||
|
// FIX E: reorg-safe adopt depth + the final pre-stop re-verification verdict.
|
||||||
|
EXPECT_EQ(dragonx::kSweepAdoptMinConfs, 10);
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(-1));
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(0));
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(1));
|
||||||
|
EXPECT_FALSE(dragonx::sweepConfsAreReorgSafe(9));
|
||||||
|
EXPECT_TRUE(dragonx::sweepConfsAreReorgSafe(10));
|
||||||
|
const double kDust = 0.0001;
|
||||||
|
EXPECT_TRUE(dragonx::adoptReverifyPasses(true, 10, 0.0, false, kDust)); // deep + empty + synced
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 9, 0.0, false, kDust)); // 1-block-reorg window
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, -1, 0.0, false, kDust)); // conflicted
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 0.0, true, kDust)); // syncing -> balance untrusted
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, 5.0, false, kDust)); // funds remain / unconfirmed
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(true, 50, -1.0, false, kDust)); // balance unknown
|
||||||
|
EXPECT_TRUE(dragonx::adoptReverifyPasses(false, 0, 0.0, false, kDust)); // genuinely empty, no sweep
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 2.0, false, kDust)); // no-funds path but unconfirmed funds
|
||||||
|
EXPECT_FALSE(dragonx::adoptReverifyPasses(false, 0, 0.0, true, kDust)); // no-funds path while syncing
|
||||||
}
|
}
|
||||||
|
|
||||||
void testLoggerFileSink()
|
void testLoggerFileSink()
|
||||||
@@ -8985,6 +9182,26 @@ void testExplorerBlockCache()
|
|||||||
EXPECT_TRUE(cache.storeBlock(block12));
|
EXPECT_TRUE(cache.storeBlock(block12));
|
||||||
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
|
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
|
||||||
|
|
||||||
|
{ // loadRangeCached memoizes on revision(): reads don't bump; a store invalidates; updates are seen.
|
||||||
|
// Isolated on its own cache so storing blocks here can't perturb the shared `cache`'s count assertions.
|
||||||
|
fs::path mdir = makeTempDir();
|
||||||
|
ExplorerBlockCache mcache((mdir / "memo.sqlite").string(), (mdir / "memo.json").string());
|
||||||
|
EXPECT_TRUE(mcache.ensureOpen());
|
||||||
|
ExplorerBlockSummary m12; m12.height = 12; m12.hash = "m-12"; m12.tx_count = 5; m12.size = 1200; m12.time = 5200; m12.difficulty = 1.4;
|
||||||
|
EXPECT_TRUE(mcache.storeBlock(m12));
|
||||||
|
const std::uint64_t rev0 = mcache.revision();
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(12, 12).size(), static_cast<size_t>(1));
|
||||||
|
EXPECT_EQ(mcache.revision(), rev0); // reading does not bump
|
||||||
|
ExplorerBlockSummary m13 = m12; m13.height = 13; m13.hash = "m-13";
|
||||||
|
EXPECT_TRUE(mcache.storeBlock(m13));
|
||||||
|
EXPECT_TRUE(mcache.revision() != rev0); // a store invalidates the memo
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2));
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(12, 13).size(), static_cast<size_t>(2)); // memo hit, same answer
|
||||||
|
m13.tx_count = 99;
|
||||||
|
EXPECT_TRUE(mcache.storeBlock(m13));
|
||||||
|
EXPECT_EQ(mcache.loadRangeCached(13, 13).at(13).tx_count, 99); // sees the update, not a stale memo
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
|
||||||
EXPECT_TRUE(reopened.ensureOpen());
|
EXPECT_TRUE(reopened.ensureOpen());
|
||||||
@@ -10886,6 +11103,66 @@ void testLiteWalletControllerCreateEncryptsWithPassphrase()
|
|||||||
EXPECT_FALSE(s2.locked);
|
EXPECT_FALSE(s2.locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIX C: (e) a create-time passphrase whose encryption FAILS is surfaced once via takeEncryptionFailureNotice() and
|
||||||
|
// leaves the wallet unencrypted; (d) the background scan save is skipped while an encrypted wallet is unlocked (the
|
||||||
|
// backend save would re-lock it) and resumes once locked.
|
||||||
|
void testLiteWalletControllerLockUnlockParity()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
|
||||||
|
const auto conn = defaultLiteConnectionSettings();
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
{ // (e)
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
dragonx::test::g_liteFakeEncryptFails = true;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletCreateRequest req;
|
||||||
|
req.passphrase = "hunter2hunter2";
|
||||||
|
(void)c->createWallet(req);
|
||||||
|
EXPECT_TRUE(c->takeEncryptionFailureNotice());
|
||||||
|
EXPECT_FALSE(c->takeEncryptionFailureNotice()); // one-shot
|
||||||
|
EXPECT_FALSE(c->encryptionStatus().encrypted);
|
||||||
|
dragonx::test::g_liteFakeEncryptFails = false;
|
||||||
|
}
|
||||||
|
{ // (e) success path raises no notice
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
LiteWalletCreateRequest req;
|
||||||
|
req.passphrase = "hunter2hunter2";
|
||||||
|
(void)c->createWallet(req);
|
||||||
|
EXPECT_FALSE(c->takeEncryptionFailureNotice());
|
||||||
|
EXPECT_TRUE(c->encryptionStatus().encrypted);
|
||||||
|
}
|
||||||
|
{ // (d)
|
||||||
|
dragonx::test::resetLiteFakeCounters();
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
auto c = std::make_unique<LiteWalletController>(
|
||||||
|
caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
|
||||||
|
EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady);
|
||||||
|
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
EXPECT_TRUE(c->encryptWallet("pw12345678").ok);
|
||||||
|
EXPECT_TRUE(c->unlockWallet("pw12345678"));
|
||||||
|
EXPECT_FALSE(c->encryptionStatus().locked);
|
||||||
|
const long saves0 = dragonx::test::g_liteFakeSaveCount.load();
|
||||||
|
EXPECT_TRUE(c->startRescan());
|
||||||
|
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
EXPECT_TRUE(c->syncComplete());
|
||||||
|
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), saves0); // no background save while unlocked
|
||||||
|
EXPECT_FALSE(c->encryptionStatus().locked); // so the unlock survived the scan
|
||||||
|
EXPECT_TRUE(c->lockWallet());
|
||||||
|
EXPECT_TRUE(c->startRescan());
|
||||||
|
for (int i = 0; i < 600 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||||
|
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() > saves0); // saves resume once locked
|
||||||
|
}
|
||||||
|
dragonx::test::g_liteFakeEncrypted = false;
|
||||||
|
dragonx::test::g_liteFakeLocked = false;
|
||||||
|
}
|
||||||
|
|
||||||
void testLiteWalletControllerAsyncLifecycleFailover()
|
void testLiteWalletControllerAsyncLifecycleFailover()
|
||||||
{
|
{
|
||||||
using namespace dragonx::wallet;
|
using namespace dragonx::wallet;
|
||||||
@@ -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()
|
void testHistoryTxidGroups()
|
||||||
{
|
{
|
||||||
using dragonx::ui::history::groupByTxid;
|
using dragonx::ui::history::groupByTxid;
|
||||||
@@ -11509,8 +11851,9 @@ void testLiteRefreshModelAppliesToWalletState()
|
|||||||
|
|
||||||
bundle.hasBalance = true;
|
bundle.hasBalance = true;
|
||||||
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
|
||||||
|
bundle.balance.spendableShieldedBalance = 200000000; // synced: all of it verified/spendable (required field)
|
||||||
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
bundle.balance.transparentBalance = 100000000; // 1 DRGX
|
||||||
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX
|
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX (mempool-incoming)
|
||||||
|
|
||||||
bundle.hasAddresses = true;
|
bundle.hasAddresses = true;
|
||||||
bundle.addresses.zAddresses = {"zs1shielded"};
|
bundle.addresses.zAddresses = {"zs1shielded"};
|
||||||
@@ -11666,6 +12009,120 @@ void testLiteSendShowsRecipientFromOutgoing()
|
|||||||
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lite sends must carry a NEGATIVE amount (full-node debit semantics) so History/Details/CSV render a
|
||||||
|
// red "-" instead of a green "+"; receives stay positive.
|
||||||
|
void testLiteSendAmountIsNegative()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
LiteWalletRefreshBundle bundle;
|
||||||
|
bundle.complete = true;
|
||||||
|
bundle.hasTransactions = true;
|
||||||
|
LiteTransactionRecord snd;
|
||||||
|
snd.txid = "s"; snd.datetime = 1700000000; snd.direction = LiteTransactionDirection::Send;
|
||||||
|
LiteTransactionOutput out; out.address = "zs1r"; out.value = 75000000;
|
||||||
|
snd.outgoingMetadata.push_back(out);
|
||||||
|
snd.amount = 75000000;
|
||||||
|
LiteTransactionRecord rcv;
|
||||||
|
rcv.txid = "r"; rcv.datetime = 1700000100; rcv.direction = LiteTransactionDirection::Receive;
|
||||||
|
rcv.address = "zs1me"; rcv.amount = 50000000;
|
||||||
|
bundle.transactions.transactions.push_back(snd);
|
||||||
|
bundle.transactions.transactions.push_back(rcv);
|
||||||
|
dragonx::WalletState state;
|
||||||
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
|
||||||
|
EXPECT_EQ(static_cast<int>(state.transactions.size()), 2);
|
||||||
|
for (const auto& t : state.transactions) {
|
||||||
|
if (t.txid == "s") { EXPECT_EQ(t.type, std::string("send")); EXPECT_NEAR(t.amount, -0.75, 1e-9); }
|
||||||
|
else { EXPECT_EQ(t.type, std::string("receive")); EXPECT_NEAR(t.amount, 0.5, 1e-9); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A send with empty outgoing_metadata (seed restore / other device) falls back to |record.amount|.
|
||||||
|
void testLiteForeignSendFallsBackToRecordAmount()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
const auto parsed = parseLiteTransactionsResponse(std::string(
|
||||||
|
R"([{"txid":"f","datetime":1700000000,"outgoing_metadata":[],"amount":-123456789}])"));
|
||||||
|
EXPECT_TRUE(parsed.ok);
|
||||||
|
EXPECT_EQ(static_cast<int>(parsed.transactions.transactions.size()), 1);
|
||||||
|
EXPECT_TRUE(parsed.transactions.transactions[0].direction == LiteTransactionDirection::Send);
|
||||||
|
EXPECT_EQ(parsed.transactions.transactions[0].amount, static_cast<std::int64_t>(123456789));
|
||||||
|
// Explicit outgoing_metadata still wins over the record amount.
|
||||||
|
const auto own = parseLiteTransactionsResponse(std::string(
|
||||||
|
R"([{"txid":"o","datetime":1,"outgoing_metadata":[{"address":"zs1","value":5}],"amount":-99}])"));
|
||||||
|
EXPECT_TRUE(own.ok);
|
||||||
|
EXPECT_EQ(own.transactions.transactions[0].amount, static_cast<std::int64_t>(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier-B: the parser range-checks amounts (reject negative / over-supply) and fails with a reason instead
|
||||||
|
// of a silent bare `return false` that rejected the whole list with an empty error.
|
||||||
|
void testLiteParserAmountRangeChecks()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
// Negative receive amount rejected with a reason.
|
||||||
|
auto neg = parseLiteTransactionsResponse(std::string(R"([{"txid":"a","datetime":1,"address":"zs1","amount":-5}])"));
|
||||||
|
EXPECT_TRUE(!neg.ok);
|
||||||
|
EXPECT_TRUE(!neg.errorMessage.empty());
|
||||||
|
// Absurd receive amount (> 21M DRGX) rejected.
|
||||||
|
auto big = parseLiteTransactionsResponse(std::string(R"([{"txid":"b","datetime":1,"address":"zs1","amount":2100000000000001}])"));
|
||||||
|
EXPECT_TRUE(!big.ok);
|
||||||
|
// Max supply itself is accepted.
|
||||||
|
auto edge = parseLiteTransactionsResponse(std::string(R"([{"txid":"c","datetime":1,"address":"zs1","amount":2100000000000000}])"));
|
||||||
|
EXPECT_TRUE(edge.ok);
|
||||||
|
// A negative outgoing output now fails with a message instead of a silent reject.
|
||||||
|
auto sneg = parseLiteTransactionsResponse(std::string(R"([{"txid":"d","datetime":1,"outgoing_metadata":[{"address":"zs1","value":-1}]}])"));
|
||||||
|
EXPECT_TRUE(!sneg.ok);
|
||||||
|
EXPECT_TRUE(!sneg.errorMessage.empty());
|
||||||
|
// Sum of outputs over the cap is rejected.
|
||||||
|
auto sover = parseLiteTransactionsResponse(std::string(R"([{"txid":"e","datetime":1,"outgoing_metadata":[{"address":"a","value":2000000000000000},{"address":"b","value":2000000000000000}]}])"));
|
||||||
|
EXPECT_TRUE(!sover.ok);
|
||||||
|
EXPECT_TRUE(!sover.errorMessage.empty());
|
||||||
|
// Absurd note value rejected.
|
||||||
|
auto note = parseLiteNotesResponse(std::string(R"({"unspent_notes":[{"address":"zs1","created_in_txid":"t","value":9000000000000000}],"utxos":[],"pending_notes":[],"pending_utxos":[]})"));
|
||||||
|
EXPECT_TRUE(!note.ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lite-parity mediums: unconfirmedBalance uses the full-node pending semantics (total - spendable, or the
|
||||||
|
// backend mempool-incoming, whichever is larger), and a t-UTXO spent by a pending tx is excluded from the
|
||||||
|
// SPENDABLE transparent total (never over-reports spendable).
|
||||||
|
void testLiteUnconfirmedAndTransparentSpendable()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
{
|
||||||
|
LiteWalletAppRefreshModel m;
|
||||||
|
m.hasBalance = true;
|
||||||
|
m.balance.shieldedZatoshis = 500000000; m.balance.spendableShieldedZatoshis = 300000000;
|
||||||
|
m.balance.transparentZatoshis = 100000000; m.balance.totalZatoshis = 600000000;
|
||||||
|
m.balance.unconfirmedZatoshis = 50000000;
|
||||||
|
dragonx::WalletState s;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.unconfirmedBalance, 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(s.totalBalance - s.spendableTotalBalance, 2.0, 1e-9);
|
||||||
|
m.balance.unconfirmedZatoshis = 900000000;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.unconfirmedBalance, 9.0, 1e-9);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
LiteWalletAppRefreshModel m;
|
||||||
|
m.hasBalance = true;
|
||||||
|
m.balance.transparentZatoshis = 300000000; m.balance.totalZatoshis = 300000000;
|
||||||
|
m.hasSpendableOutputs = true;
|
||||||
|
LiteWalletAppSpendableOutputModel a; a.kind = LiteSpendableOutputKind::Utxo; a.address = "R1"; a.valueZatoshis = 100000000; a.unconfirmedSpent = true;
|
||||||
|
LiteWalletAppSpendableOutputModel b = a; b.unconfirmedSpent = false; b.valueZatoshis = 200000000;
|
||||||
|
m.spendableOutputs = {a, b};
|
||||||
|
dragonx::WalletState s;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.transparentBalance, 3.0, 1e-9);
|
||||||
|
EXPECT_NEAR(s.spendableTransparentBalance, 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(s.spendableTotalBalance, 2.0, 1e-9);
|
||||||
|
m.hasSpendableOutputs = false;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.spendableTransparentBalance, 3.0, 1e-9);
|
||||||
|
m.hasSpendableOutputs = true; m.spendableOutputs[0].valueZatoshis = 900000000;
|
||||||
|
applyLiteRefreshModelToWalletState(m, s);
|
||||||
|
EXPECT_NEAR(s.spendableTransparentBalance, 0.0, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
||||||
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
||||||
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
||||||
@@ -11702,6 +12159,67 @@ void testLitePartialRefreshKeepsPriorAddressBalances()
|
|||||||
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lite per-address spendable: the backend's per-note 'spendable' flag drives spendableBalance (balance stays
|
||||||
|
// gross), a mature non-spendable note marks the address view-only, and a partial refresh keeps both.
|
||||||
|
void testLitePerAddressSpendableAndViewOnly()
|
||||||
|
{
|
||||||
|
using namespace dragonx::wallet;
|
||||||
|
|
||||||
|
const auto parsed = parseLiteNotesResponse(std::string(R"({
|
||||||
|
"unspent_notes":[
|
||||||
|
{"address":"zsKey","created_in_txid":"a","created_in_block":100,"value":100000000,"spendable":true},
|
||||||
|
{"address":"zsKey","created_in_txid":"b","created_in_block":995,"value":50000000,"spendable":false},
|
||||||
|
{"address":"zsView","created_in_txid":"c","created_in_block":100,"value":200000000,"spendable":false},
|
||||||
|
{"address":"zsFresh","created_in_txid":"d","created_in_block":999,"value":30000000,"spendable":false}],
|
||||||
|
"utxos":[{"address":"t1x","created_in_txid":"e","created_in_block":100,"value":70000000}],
|
||||||
|
"pending_notes":[],"pending_utxos":[]})"));
|
||||||
|
EXPECT_TRUE(parsed.ok);
|
||||||
|
if (!parsed.ok) return;
|
||||||
|
EXPECT_TRUE(parsed.notes.unspentNotes[0].spendable);
|
||||||
|
EXPECT_FALSE(parsed.notes.unspentNotes[1].spendable);
|
||||||
|
EXPECT_TRUE(parsed.notes.utxos[0].spendable); // no flag on utxos -> confirmed/unspent rule
|
||||||
|
|
||||||
|
LiteWalletRefreshBundle bundle;
|
||||||
|
bundle.complete = true;
|
||||||
|
bundle.hasAddresses = true;
|
||||||
|
bundle.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
|
||||||
|
bundle.addresses.tAddresses = {"t1x"};
|
||||||
|
bundle.hasNotes = true;
|
||||||
|
bundle.notes = parsed.notes;
|
||||||
|
bundle.hasSyncStatus = true;
|
||||||
|
bundle.syncStatus.syncedBlocks = 1000;
|
||||||
|
bundle.syncStatus.totalBlocks = 1000;
|
||||||
|
bundle.syncStatus.complete = true;
|
||||||
|
|
||||||
|
dragonx::WalletState state;
|
||||||
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(bundle).model, state);
|
||||||
|
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 3);
|
||||||
|
if (state.z_addresses.size() != 3) return;
|
||||||
|
const auto& key = state.z_addresses[0];
|
||||||
|
const auto& view = state.z_addresses[1];
|
||||||
|
const auto& fresh = state.z_addresses[2];
|
||||||
|
EXPECT_NEAR(key.balance, 1.5, 1e-9); // gross
|
||||||
|
EXPECT_NEAR(key.spendableBalance, 1.0, 1e-9); // only the anchored note
|
||||||
|
EXPECT_TRUE(key.has_spending_key);
|
||||||
|
EXPECT_NEAR(view.balance, 2.0, 1e-9);
|
||||||
|
EXPECT_NEAR(view.spendableBalance, 0.0, 1e-9);
|
||||||
|
EXPECT_FALSE(view.has_spending_key); // mature + non-spendable => view-only
|
||||||
|
EXPECT_FALSE(view.isSpendable());
|
||||||
|
EXPECT_TRUE(fresh.has_spending_key); // immature only => unknown, not mislabelled
|
||||||
|
EXPECT_NEAR(fresh.spendableBalance, 0.0, 1e-9);
|
||||||
|
EXPECT_NEAR(state.t_addresses[0].spendableBalance, 0.7, 1e-9);
|
||||||
|
|
||||||
|
// Partial refresh (notes failed): keep last-known balance, spendable and view-only verdict.
|
||||||
|
LiteWalletRefreshBundle partial;
|
||||||
|
partial.complete = true;
|
||||||
|
partial.hasAddresses = true;
|
||||||
|
partial.addresses.zAddresses = {"zsKey", "zsView", "zsFresh"};
|
||||||
|
partial.addresses.tAddresses = {"t1x"};
|
||||||
|
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
|
||||||
|
EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.0, 1e-9);
|
||||||
|
EXPECT_FALSE(state.z_addresses[1].has_spending_key);
|
||||||
|
}
|
||||||
|
|
||||||
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
|
||||||
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
|
||||||
void testLiteWalletControllerRefreshPopulatesState()
|
void testLiteWalletControllerRefreshPopulatesState()
|
||||||
@@ -11766,6 +12284,24 @@ void testLiteSyncStatusParserRealShapes()
|
|||||||
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
|
||||||
EXPECT_FALSE(p.ok);
|
EXPECT_FALSE(p.ok);
|
||||||
}
|
}
|
||||||
|
// Missing or unrecognised "syncing" must NOT default to fully synced.
|
||||||
|
{
|
||||||
|
const auto p = parseLiteSyncStatusResponse(std::string("{}"));
|
||||||
|
EXPECT_TRUE(p.ok);
|
||||||
|
EXPECT_FALSE(p.syncStatus.complete);
|
||||||
|
EXPECT_TRUE(p.syncStatus.indeterminate);
|
||||||
|
EXPECT_NEAR(p.syncStatus.progress, 0.0, 1e-9);
|
||||||
|
const auto q = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"maybe\"}"));
|
||||||
|
EXPECT_TRUE(q.ok);
|
||||||
|
EXPECT_FALSE(q.syncStatus.complete);
|
||||||
|
EXPECT_TRUE(q.syncStatus.indeterminate);
|
||||||
|
}
|
||||||
|
// Explicit idle is still caught-up and determinate.
|
||||||
|
{
|
||||||
|
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
|
||||||
|
EXPECT_TRUE(p.syncStatus.complete);
|
||||||
|
EXPECT_FALSE(p.syncStatus.indeterminate);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
|
||||||
@@ -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
|
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
|
||||||
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
|
||||||
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
|
||||||
@@ -19100,6 +19656,7 @@ int main()
|
|||||||
{
|
{
|
||||||
testConnectionConfig();
|
testConnectionConfig();
|
||||||
testClassifyRpcFailure();
|
testClassifyRpcFailure();
|
||||||
|
testSendMaybeInFlight();
|
||||||
testClassifyUnlockFailure();
|
testClassifyUnlockFailure();
|
||||||
testHttpSinkAccept();
|
testHttpSinkAccept();
|
||||||
testReadFileExact();
|
testReadFileExact();
|
||||||
@@ -19311,18 +19868,25 @@ int main()
|
|||||||
testLiteWalletControllerM5Persistence();
|
testLiteWalletControllerM5Persistence();
|
||||||
testLiteWalletControllerEncryption();
|
testLiteWalletControllerEncryption();
|
||||||
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
testLiteWalletControllerCreateEncryptsWithPassphrase();
|
||||||
|
testLiteWalletControllerLockUnlockParity();
|
||||||
testLiteWalletControllerAsyncAppliesPassphrase();
|
testLiteWalletControllerAsyncAppliesPassphrase();
|
||||||
testLiteNewPassphraseRules();
|
testLiteNewPassphraseRules();
|
||||||
testLiteChainNameMigration();
|
testLiteChainNameMigration();
|
||||||
|
testLiteUnconfirmedAndTransparentSpendable();
|
||||||
testLiteRefreshModelAppliesToWalletState();
|
testLiteRefreshModelAppliesToWalletState();
|
||||||
testLiteTransactionsAreNewestFirst();
|
testLiteTransactionsAreNewestFirst();
|
||||||
testSameTransactionList();
|
testSameTransactionList();
|
||||||
|
testTxStructureRevision();
|
||||||
testHistoryTxidGroups();
|
testHistoryTxidGroups();
|
||||||
testHistoryFilterInputs();
|
testHistoryFilterInputs();
|
||||||
testWalletStateTxRevision();
|
testWalletStateTxRevision();
|
||||||
testTransactionRefreshRevision();
|
testTransactionRefreshRevision();
|
||||||
testLiteSendShowsRecipientFromOutgoing();
|
testLiteSendShowsRecipientFromOutgoing();
|
||||||
|
testLiteSendAmountIsNegative();
|
||||||
|
testLiteForeignSendFallsBackToRecordAmount();
|
||||||
|
testLiteParserAmountRangeChecks();
|
||||||
testLitePartialRefreshKeepsPriorAddressBalances();
|
testLitePartialRefreshKeepsPriorAddressBalances();
|
||||||
|
testLitePerAddressSpendableAndViewOnly();
|
||||||
testLitePerAddressBalances();
|
testLitePerAddressBalances();
|
||||||
testLiteWalletControllerNewAddress();
|
testLiteWalletControllerNewAddress();
|
||||||
testLiteSyncStatusParserRealShapes();
|
testLiteSyncStatusParserRealShapes();
|
||||||
@@ -19331,6 +19895,7 @@ int main()
|
|||||||
testLiteWalletControllerWorkerProducesModel();
|
testLiteWalletControllerWorkerProducesModel();
|
||||||
testLiteWalletControllerWorkerChangeGate();
|
testLiteWalletControllerWorkerChangeGate();
|
||||||
testLiteClientBridgeMutationEpoch();
|
testLiteClientBridgeMutationEpoch();
|
||||||
|
testLiteClientBridgeRefusesAfterShutdown();
|
||||||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||||||
testSerialJobLane();
|
testSerialJobLane();
|
||||||
testLiteGatewayRefreshPolicyHelpers();
|
testLiteGatewayRefreshPolicyHelpers();
|
||||||
@@ -19425,6 +19990,7 @@ int main()
|
|||||||
testResolveExchangeSelection();
|
testResolveExchangeSelection();
|
||||||
testTransactionCsv();
|
testTransactionCsv();
|
||||||
testEarningsSeries();
|
testEarningsSeries();
|
||||||
|
testResilienceMediums();
|
||||||
|
|
||||||
if (g_failures != 0) {
|
if (g_failures != 0) {
|
||||||
std::cerr << g_failures << " test assertion(s) failed\n";
|
std::cerr << g_failures << " test assertion(s) failed\n";
|
||||||
|
|||||||
+47
-6
@@ -2023,9 +2023,19 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
||||||
// new branch. Only a block that spends the note (scan_block) or a reload clears
|
// new branch. Only a block that spends the note (scan_block), the send expiring
|
||||||
// it, the same as for a send that expires unmined.
|
// unmined (cleanup_mempool) or a reload clears it.
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// Same for transparent utxos: one whose spender was reorged out is unspent again
|
||||||
|
// (else it stays spent=Some(removed txid) and drops out of the balance until a rescan).
|
||||||
|
wtx.utxos.iter_mut()
|
||||||
|
.for_each(|utxo| {
|
||||||
|
if utxo.spent.is_some() && txids_to_remove.contains(&utxo.spent.unwrap()) {
|
||||||
|
utxo.spent = None;
|
||||||
|
}
|
||||||
|
// unconfirmed_spent is left as it is, for the same reason as on the notes.
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3007,16 +3017,47 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
// 2. The Tx has been added to the wallet via a mined block
|
// 2. The Tx has been added to the wallet via a mined block
|
||||||
pub fn cleanup_mempool(&self) {
|
pub fn cleanup_mempool(&self) {
|
||||||
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
||||||
|
// A send is built with Builder::new(tip) (wtx.block = tip), so its expiry_height is tip + 20 and it can
|
||||||
|
// still be mined through block tip+20 (consensus rejects it only once the height exceeds the expiry). It
|
||||||
|
// is provably dead once the chain reaches tip+21, so the pending lock on its inputs must be held until
|
||||||
|
// then — one block longer than the old drop point (tip+20), which would release the notes while the tx
|
||||||
|
// could still be mined. The point is that the lock IS released now; previously unconfirmed_spent never was.
|
||||||
|
const MEMPOOL_KEEP: i32 = DEFAULT_TX_EXPIRY_DELTA + 1;
|
||||||
|
|
||||||
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
||||||
|
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
{
|
{
|
||||||
// Remove all expired Txns
|
// Remove all expired Txns, remembering which ones so their inputs can be released below.
|
||||||
self.mempool_txs.write().unwrap().retain( | _, wtx| {
|
self.mempool_txs.write().unwrap().retain( | txid, wtx| {
|
||||||
current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA)
|
let keep = current_height < (wtx.block + MEMPOOL_KEEP);
|
||||||
|
if !keep {
|
||||||
|
expired.insert(txid.clone());
|
||||||
|
}
|
||||||
|
keep
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !expired.is_empty() {
|
||||||
|
// An expired send can never be mined: release the notes/utxos it locked (unconfirmed_spent). The
|
||||||
|
// mempool_txs lock above is released first, so mempool_txs and txs are never held together.
|
||||||
|
let mut txs = self.txs.write().unwrap();
|
||||||
|
// A txid already in txs was mined; scan_block/scan_full_tx settled its inputs, so skip it.
|
||||||
|
expired.retain(|txid| !txs.contains_key(txid));
|
||||||
|
for wtx in txs.values_mut() {
|
||||||
|
for nd in wtx.notes.iter_mut() {
|
||||||
|
if nd.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
nd.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for utxo in wtx.utxos.iter_mut() {
|
||||||
|
if utxo.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
utxo.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// Remove all txns where the txid is added to the wallet directly
|
// Remove all txns where the txid is added to the wallet directly
|
||||||
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ pub struct SaplingNoteData {
|
|||||||
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
||||||
pub memo: Option<Memo>,
|
pub memo: Option<Memo>,
|
||||||
pub is_change: bool,
|
pub is_change: bool,
|
||||||
// TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -987,7 +987,28 @@ impl Sim {
|
|||||||
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
||||||
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
||||||
let rel = &self.rel;
|
let rel = &self.rel;
|
||||||
self.mempool.retain(|t, b| !ids.contains(t) && !rel.contains_key(t) && h < *b + TX_EXPIRY_DELTA);
|
// Mirror cleanup_mempool: it keeps a send for MEMPOOL_KEEP (= TX_EXPIRY_DELTA + 1, i.e. one block past the
|
||||||
|
// tx's expiry height, when it is provably dead) past the sending tip, and for a send dropped purely by
|
||||||
|
// expiry (never mined, i.e. not in `ids`/`rel`/the wallet's `txs`) it now RELEASES the pending lock on the
|
||||||
|
// inputs. So clear `pending` for those, exactly as the wallet clears `unconfirmed_spent`. The real tx
|
||||||
|
// expiry stays TX_EXPIRY_DELTA (used at verify_tx below).
|
||||||
|
const MEMPOOL_KEEP: i32 = TX_EXPIRY_DELTA + 1;
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
|
self.mempool.retain(|t, b| {
|
||||||
|
let mined = ids.contains(t) || rel.contains_key(t);
|
||||||
|
let keep = !mined && h < *b + MEMPOOL_KEEP;
|
||||||
|
if !keep && !mined {
|
||||||
|
expired.insert(*t);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
});
|
||||||
|
if !expired.is_empty() {
|
||||||
|
for n in self.notes.iter_mut() {
|
||||||
|
if n.pending.map_or(false, |p| expired.contains(&p)) {
|
||||||
|
n.pending = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
||||||
.cloned().collect();
|
.cloned().collect();
|
||||||
|
|||||||
Reference in New Issue
Block a user