6 Commits

Author SHA1 Message Date
ea26c0cbbb fix(balance): stop the displayed balance cratering during a pending shielded send
Sending a small amount from an address holding a large balance made the displayed
balance collapse to ~0 until the tx confirmed. A shielded spend consumes the whole
source note; the change returns as a 0-confirmation note, and every balance query
used the default minconf=1 — so the spent note dropped out and the change wasn't
counted yet.

Split every balance into two views:

- DISPLAY (balance / privateBalance / transparentBalance / totalBalance) — now
  queried at minconf=0, so it INCLUDES the user's own pending change and no longer
  craters. This is what the Overview, balance tab, market portfolio and receive
  tab show. unconfirmedBalance is now populated (= total - spendable).
- SPENDABLE (new spendableBalance / spendable*Balance) — confirmed (minconf>=1),
  what z_sendmany (run at minconf=1) can actually spend. The Send form's available/
  Max/validation, the from-address selection, the drag-to-transfer dialog cap, the
  chat pay-from and the auto-shield gate all size off these, so they never offer
  0-conf change the daemon would reject.

Implementation: a single z_listunspent(0)/listunspent(0), partitioned per-note by
"confirmations">=1; z_gettotalbalance called at minconf 0 (display) and 1
(spendable); the z_getbalance fallback queries both. applyPendingSendDelta (the
optimistic post-send debit) now touches ONLY the spendable fields — debiting the
display too would re-crater it on top of the honest minconf=0 RPC. Lite mirrors
spendableBalance = balance (its per-address balance is already confirmed) so lite
sends aren't zeroed. The confirmed-only gates (seed-migration/sweep z_gettotalbalance,
sweep z_getbalance(addr,1), z_sendmany's minconf arg) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 16:40:14 -05:00
13b225d8f5 fix(send): prefix shielded-send memos with "utf8:" so the daemon accepts them
A shielded send with a memo failed: "Invalid parameter, expected memo data in
hexadecimal format or to use 'utf8:' prefix." The Send-tab path (and its fee-gap
retry) put the user's plain-text memo straight into the z_sendmany recipient,
which the daemon now rejects — it wants the memo hex-encoded or with a "utf8:"
prefix. The chat path already prefixes with "utf8:"; do the same for user memos.

Only the RPC recipient["memo"] is prefixed; the raw memo is still what's stored
for the transaction-history display, and the daemon returns the decoded memoStr
to receivers, so it round-trips as plain text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 15:49:09 -05:00
08aed34bbe fix(daemon): actually stop an external daemon on quit when the setting is on
"Stop external daemon" silently left an external dragonxd running. beginShutdown()
calls rpc_->requestAbort() (a sticky abort flag, cleared only by connect()) to
unblock in-flight requests; the shutdown thread's stopEmbeddedDaemon() then sent
the graceful "stop" over that same connection, so curl self-aborted it
(CURLE_ABORTED_BY_CALLBACK). doRPC swallowed the error but stop_sent was set true
anyway, skipping the temp-connection fallback that would have worked — so the
daemon never received "stop" and only died via the 20s by-name force-kill (which
collides with the 8s "Force Quit / may corrupt chain data" prompt, so it read as
"doesn't work").

Clear the abort before the shutdown stop and send it synchronously via
sendStopCommandSafely so real delivery success is surfaced (and the fallback can
still run on failure). The graceful stop now reaches the daemon, it exits in a
second or two, and the 20s stall / Force-Quit prompt no longer appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 00:08:08 -05:00
5edbe8a276 feat(recovery): redesign the wallet auto-recovery flow
Turns the "Daemon Error + raw log dump" moment into one calm, honest recovery
dialog plus a recovery-aware rescan screen. Presentation + orchestration only —
the file-safety logic in rebuildWalletDatabase()/restoreOriginalWallet() (source
selection, verify-before-swap, copy/rename-never-delete, .bak) is unchanged.

- One authoritative dialog with a phase machine Offer -> Working -> Done/Failed.
  The duplicate in-overlay recovery card, the untranslated red "Daemon Error"
  heading, and the raw daemon-log dump are gone for the recovery case (they stay
  for genuine, unrelated crashes).
- Offer is a choice-cards layout: "Repair automatically" (recommended, accent-
  tinted) vs "Restore original", side by side; the rare actions ("Show me the
  files", "Decide later") and a plain-language "What happens to my files?" sit in
  a quiet footer. When the rebuild helper is missing, it collapses to a single
  Restore card — never a dead end.
- Post-repair rescan shows a calm "Finishing your wallet repair" screen with
  elapsed time + the growing wallet size, instead of "RPC timeout / taking longer
  than expected / restart daemon"; the daemon-crash toast is suppressed and the
  detection toast is downgraded from red to info.
- Fixes a confirmed dead-end: if a repair succeeds but the restarted daemon then
  crashes for a *different* reason (block index, disk, OOM), the recovery flags
  now clear (in tryConnect + onConnected) so it surfaces as a normal daemon
  failure instead of freezing forever on a reassuring "don't restart" screen.
- Clickable "Wallet repair available" status-bar chip for re-entry.

The same app.cpp changes HiDPI-harden the surfaces the recovery flow lives on:
the status-bar and loading-overlay hand-drawn geometry are multiplied by dpiScale
(they rendered native-size and clipped at HiDPI / font_scale>1), the loading-
overlay status text wraps instead of running off both edges, and the node-status
banner floors its height to its DPI-baked font so the title can't clip off the top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:31:55 -05:00
001e85ac1a fix(ui): prevent text/button cutoff and scale hand-drawn geometry at HiDPI
Findings from a UI-cutoff audit — each is a spot where an in-tree helper
(truncateMiddle / TruncateToWidth / measured button width / the *dpiScale/*hs
factors) was bypassed:

- notifications: the toast-pill height/padding/icon-gap were raw logical px while
  the icon/text drawn inside are DPI-baked, so they clipped the pill at HiDPI.
  Scale the geometry by dpiScale (not the already-scaled glyph metrics).
- settings: in the two-column NODE & SECURITY layout the data-directory path could
  overrun into the Daemon-binary column (shared draw list, no clip rect between
  them). Middle-ellipsize it to the column width; the full path stays in the
  tooltip + click-to-open + copy.
- send: the "Confirm & Send" button width came straight from the schema and was
  never measured against the label, clipping the Russian translation on the
  pre-broadcast dialog. Size to max(schema width, measured label + padding).
- balance: the recent-tx address-column offset missed the `* hs` DPI factor its
  sibling (amount-right-margin) uses, overlapping the type label at HiDPI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:11:07 -05:00
393f3d147e feat(recovery): bundle & embed the offline wallet-rebuild helper in every release
The dragonx-wallet-rebuild helper is the only thing that repairs a genuinely
BDB-inconsistent wallet.dat — plain "Restore" just re-triggers the daemon's
salvage cascade — yet it was silently dropped from every packaged build:

- Linux zip/AppImage copied a hand-picked file list that omitted it.
- Windows bundled it only behind a soft `[[ -f ]]` guard (silent skip).
- macOS never wired Berkeley DB, never built it, never bundled it.

build.sh now HARD-REQUIRES the helper for full-node releases (fails the build if
the vendored Berkeley DB depends are missing, rather than shipping recovery-less),
and ships it in the Linux zip + AppImage, the Windows zip, and the macOS .app.

It also compiles the helper standalone for Windows and INCBINs it, and
embedded_resources gains ensureWalletRebuildHelperExtracted() to extract it on
demand — so a self-contained ObsidianDragon.exe carries recovery exactly like the
embedded daemon, even on a machine where first-run param extraction already ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:10:57 -05:00
18 changed files with 894 additions and 211 deletions

View File

@@ -195,6 +195,24 @@ should_bundle_full_node_assets() {
! $DO_LITE
}
# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
require_wallet_rebuild_helper() {
local helper="$1" depends="$2"
should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
if [[ ! -f "$helper" ]]; then
err "wallet-rebuild helper was NOT built: $helper"
err " → the recovery 'Repair automatically' option would be MISSING from this release."
err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
exit 1
fi
info " wallet-rebuild helper present: $helper"
}
# ── Helper: find resource files ──────────────────────────────────────────────
find_sapling_params() {
local dirs=(
@@ -286,13 +304,8 @@ bundle_linux_daemon() {
# asmap.dat
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
for p in "$SCRIPT_DIR/build/linux/bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/../dragonx-wallet-rebuild"; do
if [[ -f "$p" ]]; then
cp "$p" "$dest/dragonx-wallet-rebuild"; chmod +x "$dest/dragonx-wallet-rebuild"
info " Bundled dragonx-wallet-rebuild"; break
fi
done
# (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
# by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
return $found
}
@@ -351,6 +364,10 @@ build_release_linux() {
local BDB_ARGS=()
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
elif should_bundle_full_node_assets; then
err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
exit 1
fi
info "Configuring ..."
cmake "$SCRIPT_DIR" \
@@ -365,6 +382,9 @@ build_release_linux() {
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
info "Stripping ..."
strip "bin/${APP_BASENAME}"
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
@@ -402,6 +422,9 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
info " Bundled dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
@@ -435,6 +458,8 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
@@ -656,6 +681,30 @@ HDR
info "Lite mode: skipping embedded daemon binaries"
fi
# ── Wallet-rebuild recovery helper ───────────────────────────────
# Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
# main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
# ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
if should_bundle_full_node_assets; then
local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
info "Compiling + embedding wallet-rebuild helper ..."
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
-I"$SCRIPT_DIR/src" -I"$WBDB/include" \
"$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
"$WBDB/lib/libdb-6.2.a" -lws2_32 \
-o "$RES/dragonx-wallet-rebuild.exe" \
|| { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
else
err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
exit 1
fi
fi
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
@@ -775,6 +824,10 @@ HDR
local BDB_ARGS=()
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
elif should_bundle_full_node_assets; then
err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
exit 1
fi
info "Configuring (cross-compile) ..."
cmake "$SCRIPT_DIR" \
@@ -791,6 +844,9 @@ HDR
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
# ── Package: release/windows/ ────────────────────────────────────────────
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
mkdir -p "$out"
@@ -806,8 +862,9 @@ HDR
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
done
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
[[ -f "bin/dragonx-wallet-rebuild.exe" ]] && { cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/"; info " Bundled dragonx-wallet-rebuild.exe"; }
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
[[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
# Bundle Sapling params + asmap for the zip distribution
# (The single-file exe has these embedded via INCBIN, but the zip
@@ -1063,6 +1120,11 @@ TOOLCHAIN
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/external/dragonx/depends/aarch64-apple-darwin"
# Strip — use osxcross strip for cross-builds
if $IS_CROSS; then
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
@@ -1135,6 +1197,8 @@ TOOLCHAIN
else
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
fi
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
else
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi

View File

@@ -1644,8 +1644,10 @@ void App::render()
float v = ui::schema::UI().drawElement("components.sidebar", key).size;
return (v >= 0 ? v : fb) * dp;
};
float statusBarH = ui::schema::UI().window("components.status-bar").height;
if (statusBarH <= 0.0f) statusBarH = 24.0f; // safety fallback
// Scale by dp to match the rendered status-bar child height (renderStatusBar), so the reserved
// content strip stays in step with the bar at HiDPI instead of under-reserving.
float statusBarH = ui::schema::UI().window("components.status-bar").height * dp;
if (statusBarH <= 0.0f) statusBarH = 24.0f * dp; // safety fallback
// Content area padding from ui.toml schema
const auto& caWin = ui::schema::UI().window("components.content-area");
const float caMarginTop = ui::schema::UI().drawElement("components.content-area", "margin-top").size;
@@ -2189,9 +2191,18 @@ void App::renderNodeStatusBanner()
const auto& S = ui::schema::UI();
const float minH = S.drawElement("banners.node-status", "min-height").size;
const float baseH = S.drawElement("banners.node-status", "height").size;
// Fonts up-front so the banner height can never be shorter than the glyph row it vertically
// centers — otherwise the title/icon draw above the child's top clip rect and slice off (seen at
// HiDPI / large font scale, where the DPI-baked glyphs outgrow the schema height). Metrics scaled.
ImFont* icoFont = m::Type().iconSmall();
ImFont* txtFont = m::Type().body2();
const float glyphH = std::max(icoFont ? icoFont->LegacySize : 0.0f,
txtFont ? txtFont->LegacySize : 0.0f);
// Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height
// floor needs the same dpiScale() or it under-clamps the banner at HiDPI.
const float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
bannerH = std::max(bannerH, glyphH + ui::Layout::spacingSm() * 2.0f); // never shorter than text
const bool isError = (banner.severity == ui::NodeBannerSeverity::Error);
const ImU32 sevCol = isError ? m::Error() : m::Warning();
@@ -2222,18 +2233,15 @@ void App::renderNodeStatusBanner()
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
const float winW = ImGui::GetWindowSize().x;
ImFont* icoFont = m::Type().iconSmall();
ImFont* txtFont = m::Type().body2();
// Icon — centered on its own metrics.
ImGui::SetCursorPos(ImVec2(padX, (bannerH - icoFont->LegacySize) * 0.5f));
// Icon — centered on its own metrics (clamped so it never draws above the child's top).
ImGui::SetCursorPos(ImVec2(padX, std::max(0.0f, (bannerH - icoFont->LegacySize) * 0.5f)));
ImGui::PushFont(icoFont);
ImGui::PushStyleColor(ImGuiCol_Text, sevCol);
ImGui::TextUnformatted(icon);
ImGui::PopStyleColor();
ImGui::PopFont();
const float txtCy = (bannerH - txtFont->LegacySize) * 0.5f;
const float txtCy = std::max(0.0f, (bannerH - txtFont->LegacySize) * 0.5f);
// Right-aligned action button geometry (measured first so the detail text can be clipped to
// never run underneath it).
@@ -2281,7 +2289,7 @@ void App::renderNodeStatusBanner()
// Action button.
if (actionLabel) {
ImGui::SetCursorPos(ImVec2(winW - btnW - padX, (bannerH - btnH) * 0.5f));
ImGui::SetCursorPos(ImVec2(winW - btnW - padX, std::max(0.0f, (bannerH - btnH) * 0.5f)));
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
@@ -2388,12 +2396,15 @@ void App::renderStatusBar()
// Status bar layout from unified UI schema
const auto& S = ui::schema::UI();
const auto& sbWin = S.window("components.status-bar");
const float sbHeight = sbWin.height;
const float sbPadX = sbWin.padding[0];
const float sbPadY = sbWin.padding[1];
const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size;
const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size;
const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size;
// Schema values are logical px; the fonts/icons drawn inside are DPI-baked, so the box and its
// gaps must be scaled by the same factor or they clip the text at HiDPI / font_scale > 1.
const float dp = ui::Layout::dpiScale();
const float sbHeight = sbWin.height * dp;
const float sbPadX = sbWin.padding[0] * dp;
const float sbPadY = sbWin.padding[1] * dp;
const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size * dp;
const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size * dp;
const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size * dp;
ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoScrollWithMouse;
@@ -2625,6 +2636,8 @@ void App::renderStatusBar()
// Compute positions dynamically from actual text widths so they
// never overlap and always stay within the window at any font scale.
{
// Where the left-side chain ended, so the right cluster never SameLine()s backward onto it.
const float leftEndX = ImGui::GetCursorPosX();
char versionBuf[32];
snprintf(versionBuf, sizeof(versionBuf), "v%s", DRAGONX_VERSION);
float versionW = ImGui::CalcTextSize(versionBuf).x;
@@ -2636,10 +2649,36 @@ void App::renderStatusBar()
float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") {
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
// During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a
// calm line instead (the rescan legitimately can't answer RPC yet).
const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair"))
: connection_status_;
// Middle-ellipsize to the space between the left chain and the version so a long status
// (e.g. "Wallet needs recovery — see the prompt") can't push off-screen or overprint the
// left chain; then clamp its start so it never crosses left of where the chain ended.
float availW = versionX - leftEndX - gap * 2.0f;
if (availW < 24.0f * dp) availW = 24.0f * dp;
ImFont* stFont = ImGui::GetFont();
std::string statusShown = ui::material::TruncateToWidth(
statusBase, stFont, stFont->LegacySize, availW);
float statusW = ImGui::CalcTextSize(statusShown.c_str()).x;
float statusX = versionX - statusW - gap;
if (statusX < leftEndX + gap) statusX = leftEndX + gap;
ImGui::SameLine(statusX);
ImGui::TextDisabled("%s", connection_status_.c_str());
if (wallet_auto_recovered_ && !post_recovery_rescan_) {
// Actionable re-entry: a full-opacity accent chip that reopens the recovery dialog.
// Dismiss is non-destructive, so this is the guaranteed way back to the prompt.
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
ImGui::TextUnformatted(statusShown.c_str());
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsItemClicked() && recovery_phase_ != RecoveryPhase::Working) {
show_wallet_recovered_dialog_ = true; // reopen the (non-destructively dismissed) prompt
recovery_phase_ = RecoveryPhase::Offer;
}
} else {
ImGui::TextDisabled("%s", statusShown.c_str());
}
occupiedX = statusX;
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
const char* errText = TR("sb_daemon_not_found");
@@ -2667,7 +2706,6 @@ void App::renderStatusBar()
// including ones whose toast already faded; an unread dot marks alerts that arrived since
// the panel was last opened.
{
const float dp = ui::Layout::dpiScale();
auto& notes = ui::Notifications::instance();
ImFont* bellFont = ui::material::Type().iconSmall();
const bool anyHist = notes.hasHistory();
@@ -3994,10 +4032,17 @@ void App::renderSeedMigrationDialog()
ImGui::TextWrapped("%s", TR("mig_already_mnemonic"));
ImGui::PopStyleColor();
ImGui::Spacing();
if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(200 * dp, 0))) {
{
// Size to the label — the fixed 200px clipped the FR/PT/ES/DE/RU translations.
ImFont* bkFont = ui::material::Type().button();
const float bkW = std::max(200.0f * dp,
bkFont->CalcTextSizeA(bkFont->LegacySize, FLT_MAX, 0, TR("mig_backup_instead")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(bkW, 0))) {
close();
showSeedBackupDialog();
}
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close();
break;
@@ -4167,8 +4212,12 @@ void App::renderSeedMigrationDialog()
ImGui::Spacing();
char cbuf[64]; snprintf(cbuf, sizeof(cbuf), TR("mig_confs"), seed_migration_sweep_confs_);
ImGui::TextColored(kMedium, "%s", cbuf);
if (!seed_migration_sweep_txid_.empty())
ImGui::TextColored(kMedium, "%s%s", TR("mig_txid"), seed_migration_sweep_txid_.c_str());
if (!seed_migration_sweep_txid_.empty()) {
// A 64-hex txid overflows the fixed 580px card; render it in a wrapping, copyable field
// (same widget the import-key sweep result uses) instead of a raw one-line label.
ImGui::TextColored(kMedium, "%s", TR("mig_txid"));
ui::widgets::AddressCopyField("##migtxid", seed_migration_sweep_txid_);
}
if (seed_migration_legacy_remaining_ >= 0.0) {
char rbuf[96]; snprintf(rbuf, sizeof(rbuf), TR("mig_remaining"), seed_migration_legacy_remaining_);
ImGui::TextColored(kMedium, "%s", rbuf);
@@ -4292,39 +4341,319 @@ void App::renderWalletRecoveredDialog()
ui::material::OverlayDialogSpec ov;
ov.title = TR("wallet_recovered_title");
ov.p_open = &show_wallet_recovered_dialog_;
// Dismiss (backdrop / [X]) is disabled during Working — files are mid-swap. A null p_open is safe:
// BeginOverlayDialog guards both the close button and backdrop-close on it.
ov.p_open = (recovery_phase_ == RecoveryPhase::Working) ? nullptr : &show_wallet_recovered_dialog_;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 560.0f;
ov.cardWidth = 620.0f; // wide enough for the two side-by-side choice cards
ov.idSuffix = "walletrecovered";
if (!ui::material::BeginOverlayDialog(ov)) return;
const float dp = ui::Layout::dpiScale();
// Size each button to its label (with a modest floor) instead of a magic fixed width, so
// translations of these keys — added additively to res/lang later — can't silently clip.
ImFont* rbf = ui::material::Type().button();
auto fitBtnW = [&](const char* label) {
return std::max(96.0f * dp,
rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
};
// A full-width action row: the button, then a dim wrapped one-line explanation of what it does.
auto actionRow = [&](const char* label, const char* sub) -> bool {
const bool clicked = ui::material::TactileButton(label, ImVec2(fitBtnW(label), 0));
if (sub && sub[0]) {
ImGui::PushFont(ui::material::Type().caption());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextDisabled("%s", sub);
ImGui::PopTextWrapPos();
ImGui::PopFont();
}
return clicked;
};
// A disclosure that recedes: no filled bar, dim label — so the primary action stays dominant.
auto quietHeader = [&](const char* label) -> bool {
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::material::WithAlpha(ui::material::OnSurface(), 20));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ui::material::WithAlpha(ui::material::OnSurface(), 32));
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
const bool open = ImGui::CollapsingHeader(label);
ImGui::PopStyleColor(4);
return open;
};
// Opt-in daemon log — the same lines that used to be dumped on screen, now behind a quiet disclosure.
auto techDetails = [&]() {
if (!daemon_controller_) return;
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
if (quietHeader(TR("wallet_recovery_details_label"))) {
ImGui::PushFont(ui::material::Type().caption());
ImGui::PushTextWrapPos(0.0f);
for (const auto& ln : daemon_controller_->recentLines(8))
ImGui::TextDisabled("%s", ln.c_str());
ImGui::PopTextWrapPos();
ImGui::PopFont();
}
};
// Funds-safety hero: a shield icon + the reassurance, made the visual focal point of the screen.
auto safetyHero = [&]() {
ImFont* icoF = ui::material::Type().iconLarge();
ImFont* txtF = ui::material::Type().subtitle1();
const float rowTop = ImGui::GetCursorPosY();
ImGui::PushFont(icoF);
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_HEALTH_AND_SAFETY);
ImGui::PopFont();
ImGui::SameLine();
const float iconH = icoF->LegacySize;
const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize();
if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
ImGui::PushFont(txtF);
ImGui::TextColored(ui::material::SuccessVec4(), "%s", TR("wallet_recovered_safety"));
ImGui::PopFont();
};
// Accent-tinted primary action so the recommended button clearly dominates the quiet disclosures
// (a translucent Primary tint over the glass button — keeps the label readable on any theme).
auto primaryAction = [&](const char* label, const char* sub) -> bool {
ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 60));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 90));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 115));
const bool clicked = actionRow(label, sub);
ImGui::PopStyleColor(3);
return clicked;
};
// A choice card: icon + title (+ optional "recommended" chip) + wrapped blurb + a full-width button
// pinned to the card bottom. The recommended card gets a gold-tinted fill + border so the choice
// reads at a glance. cardH is precomputed by the caller so side-by-side cards stay equal height.
auto renderCard = [&](const char* id, const char* glyph, const char* title, const char* desc,
const char* btn, bool recommended, float cardW, float cardH) -> bool {
const float pad = ui::Layout::spacingMd();
ImGui::PushStyleColor(ImGuiCol_ChildBg,
recommended ? ui::material::WithAlpha(ui::material::Primary(), 22)
: ui::material::WithAlpha(ui::material::OnSurface(), 8));
ImGui::PushStyleColor(ImGuiCol_Border,
recommended ? ui::material::WithAlpha(ui::material::Primary(), 140)
: ui::material::WithAlpha(ui::material::OnSurface(), 28));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad));
ImGui::BeginChild(id, ImVec2(cardW, cardH), true,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Zero implicit item spacing so the only vertical gaps are the explicit Dummy()s below — that
// keeps the caller's measured cardH exact, so the bottom-pinned button never clips.
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f));
ui::material::DialogWarningHeader(TR("wallet_recovered_warn"));
ImGui::PushFont(ui::material::Type().iconMed());
ImGui::TextColored(recommended ? ui::material::PrimaryVec4()
: ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()),
"%s", glyph);
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
ImGui::PushFont(ui::material::Type().subtitle2());
ImGui::TextUnformatted(title);
ImGui::PopFont();
if (recommended) {
ImGui::PushFont(ui::material::Type().caption());
ImGui::TextColored(ui::material::PrimaryVec4(), "%s", TR("wallet_recovery_recommended"));
ImGui::PopFont();
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
ImGui::PushFont(ui::material::Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", desc);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
// Pin the button to the card bottom so both cards' buttons line up.
const float btnH = ImGui::GetFrameHeight() + 6.0f * dp;
const float remaining = ImGui::GetContentRegionAvail().y - btnH;
if (remaining > 0.0f) ImGui::Dummy(ImVec2(0, remaining));
bool clicked;
if (recommended) {
ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125));
clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
ImGui::PopStyleColor(3);
} else {
clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH));
}
ImGui::PopStyleVar(); // ItemSpacing
ImGui::EndChild();
ImGui::PopStyleVar(3);
ImGui::PopStyleColor(2);
return clicked;
};
// A quiet clickable text link for the footer (Show me the files / Not now).
auto linkText = [&](const char* label) -> bool {
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextUnformatted(label);
ImGui::PopStyleColor();
const bool clicked = ImGui::IsItemClicked();
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax();
ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y),
ui::material::OnSurface());
}
return clicked;
};
switch (recovery_phase_) {
case RecoveryPhase::Offer: {
safetyHero();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
ImGui::TextWrapped("%s", TR("wallet_recovered_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Preferred fix when available: REBUILD the wallet database. Plain "Restore original" hands the same
// BDB-inconsistent file back and the daemon just re-salvages it (the cascade); the rebuild produces a
// fresh, consistent copy of every key that the daemon loads cleanly. Copy/rename-only — never deletes.
if (walletRebuildAvailable()) {
if (ui::material::TactileButton(TR("wallet_recovered_rebuild"), ImVec2(280.0f * dp, 0))) {
rebuildWalletDatabase(); // clears show_wallet_recovered_dialog_
// The two real actions as side-by-side CHOICE CARDS — the recommended one gold-tinted, so the
// decision reads at a glance instead of hiding in a menu. Rebuild is the more complete fix; when
// its helper is missing, Restore becomes the single recommended card (never a dead end).
const bool canRebuild = walletRebuildAvailable();
const float cardGap = ui::Layout::spacingMd();
const float pad = cardGap;
const float contentW = ImGui::GetContentRegionAvail().x;
const float cardW = canRebuild ? (contentW - cardGap) * 0.5f
: std::min(contentW, 320.0f * dp);
// Fix a shared card height off the taller card body so the two cards line up. Measure title AND
// desc wraps (titles/descs can be multi-line, esp. after translation), with a little width slack.
ImFont* descF = ui::material::Type().caption();
ImFont* titleF = ui::material::Type().subtitle2();
const float innerW = std::max(1.0f, cardW - 2.0f * pad - 6.0f * dp);
auto measureH = [&](ImFont* f, const char* s) {
return f->CalcTextSizeA(f->LegacySize, FLT_MAX, innerW, s).y;
};
const float titleH = std::max(measureH(titleF, TR("wallet_recovery_rebuild_card")),
measureH(titleF, TR("wallet_recovery_restore_card")));
const float descH = std::max(measureH(descF, TR("wallet_recovery_rebuild_card_desc")),
measureH(descF, TR("wallet_recovery_restore_card_desc")));
const float cardH = 2.0f * pad
+ ui::material::Type().iconMed()->LegacySize + ui::Layout::spacingXs() // icon + gap
+ titleH + descF->LegacySize // title + RECOMMENDED chip
+ ui::Layout::spacingXs() // gap before desc
+ descH + ui::Layout::spacingSm() // desc + gap before button
+ ImGui::GetFrameHeight() + 6.0f * dp // button
+ ui::Layout::spacingXs(); // small buffer
if (canRebuild) {
const bool r = renderCard("##rcRepair", ICON_MD_AUTO_FIX_HIGH, TR("wallet_recovery_rebuild_card"),
TR("wallet_recovery_rebuild_card_desc"), TR("wallet_recovery_repair_go"),
true, cardW, cardH);
ImGui::SameLine(0, cardGap);
const bool s = renderCard("##rcRestore", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
false, cardW, cardH);
if (r) rebuildWalletDatabase();
if (s) restoreOriginalWallet();
} else {
const float indent = (contentW - cardW) * 0.5f;
if (indent > 0.0f) ImGui::Indent(indent);
const bool s = renderCard("##rcRestoreOnly", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"),
TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"),
true, cardW, cardH);
if (indent > 0.0f) ImGui::Unindent(indent);
if (s) restoreOriginalWallet();
}
// Quiet footer: inspect-files link + a clearly-labelled, reversible dismiss (with a tooltip that
// spells out the consequence — plain "Not now" was ambiguous).
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (linkText(TR("wallet_recovered_open_folder")))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
ImGui::SameLine(0, ui::Layout::spacingSm());
ImGui::TextDisabled("\xC2\xB7"); // middle dot separator
ImGui::SameLine(0, ui::Layout::spacingSm());
if (linkText(TR("wallet_recovery_decide_later"))) {
show_wallet_recovered_dialog_ = false; // non-destructive; reopen from the status bar
recovery_phase_ = RecoveryPhase::Offer;
}
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f);
ImGui::TextUnformatted(TR("wallet_recovery_decide_later_tip"));
ImGui::PopTextWrapPos();
ImGui::EndTooltip();
}
// Plain-language "what happens to my files" — replaces the unhelpful raw daemon log on this screen
// (the log is still available in the Console tab for troubleshooting).
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
if (quietHeader(TR("wallet_recovery_files_label"))) {
ImGui::PushFont(ui::material::Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::PushTextWrapPos(0.0f);
ImGui::TextWrapped("%s", TR("wallet_recovery_files_detail"));
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
}
break;
}
case RecoveryPhase::Working: {
ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_rebuild_started")
: TR("wallet_restore_started"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
ImGui::Text("%s%s", TR("wallet_recovery_working_label"), ui::material::LoadingDots());
break;
}
case RecoveryPhase::Done: {
safetyHero();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_recovery_success_body")
: TR("wallet_recovery_success_restore"));
// A sev-1 warning (e.g. restored but the node didn't relaunch) carries a specific message.
if (recovery_outcome_sev_ == 1 && !recovery_outcome_msg_.empty()) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning());
ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
ImGui::PopStyleColor();
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("wallet_recovery_done"), ImVec2(fitBtnW(TR("wallet_recovery_done")), 0))) {
show_wallet_recovered_dialog_ = false;
recovery_phase_ = RecoveryPhase::Offer;
// Clean success — clear the session flag so a later unrelated disconnect doesn't re-raise the
// "repair available" chip/dialog for a wallet that's already fixed. (Warnings keep it set.)
if (recovery_outcome_sev_ == 0) wallet_auto_recovered_ = false;
}
techDetails();
break;
}
case RecoveryPhase::Failed: {
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
ImGui::TextWrapped("%s", TR("wallet_recovery_failure_title"));
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("wallet_recovery_failure_body"));
if (!recovery_outcome_msg_.empty()) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs()));
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str());
ImGui::PopStyleColor();
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Offer the UNtried option (Restore needs no helper; Rebuild only if its helper exists).
if (recovery_last_action_rebuild_) {
if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_restore_sub")))
restoreOriginalWallet();
} else if (walletRebuildAvailable()) {
if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_rebuild_sub")))
rebuildWalletDatabase();
}
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
if (actionRow(TR("wallet_recovered_open_folder"), TR("wallet_recovered_open_folder_sub")))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
if (ui::material::TactileButton(TR("close"), ImVec2(fitBtnW(TR("close")), 0))) {
show_wallet_recovered_dialog_ = false;
recovery_phase_ = RecoveryPhase::Offer;
}
// Restore of the untouched original (stops the node, swaps the .bak back over the salvaged copy,
// clears the stale BDB env, restarts).
if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) {
restoreOriginalWallet(); // clears show_wallet_recovered_dialog_
techDetails();
break;
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("wallet_recovered_open_folder"), ImVec2(200.0f * dp, 0))) {
util::Platform::openFolder(util::Platform::getDragonXDataDir()); // manual restore instead
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("wallet_recovered_dismiss"), ImVec2(150.0f * dp, 0))) {
show_wallet_recovered_dialog_ = false; // acknowledged; keeps the salvaged wallet loaded
}
ui::material::EndOverlayDialog();
}
@@ -4718,17 +5047,17 @@ void App::stopEmbeddedDaemon()
// daemon flush state, save block indexes, close sockets, etc.
bool stop_sent = false;
// Try the existing RPC connection first
// Try the existing RPC connection first. beginShutdown() called rpc_->requestAbort() to unblock any
// in-flight request — but that sticky abort (cleared only by connect()) would make THIS "stop" self-
// abort mid-flight (CURLE_ABORTED_BY_CALLBACK). The old async rpc_->stop() swallowed that error yet
// set stop_sent=true, so the daemon never heard "stop" and the working temp-connection fallback was
// skipped — which is why "Stop external daemon" left an external dragonxd running (it only died via
// the 20s force-kill). Clear the abort and send SYNCHRONOUSLY so real success is surfaced.
if (rpc_ && rpc_->isConnected()) {
DEBUG_LOGF("Sending stop command via existing RPC connection...\n");
try {
rpc_->stop([](const json&) {
DEBUG_LOGF("Stop command acknowledged by daemon\n");
});
rpc_->clearAbort();
if (sendStopCommandSafely(*rpc_, "existing connection stop"))
stop_sent = true;
} catch (...) {
DEBUG_LOGF("Failed to send stop via existing connection\n");
}
}
// If the main connection wasn't established (e.g. daemon was still
@@ -5462,6 +5791,10 @@ void App::renderLoadingOverlay(float contentH)
using namespace ui::material;
constexpr float kPi = 3.14159265f;
// The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading
// spinner underneath it so "still loading" and "make a decision" are never the same screen.
if (show_wallet_recovered_dialog_) return;
auto loadElem = [](const char* key, float fb) {
float v = ui::schema::UI().drawElement("screens.loading", key).size;
return v >= 0 ? v : fb;
@@ -5473,12 +5806,17 @@ void App::renderLoadingOverlay(float contentH)
ImVec2 wp = ImGui::GetWindowPos();
ImVec2 ws = ImGui::GetWindowSize();
// Hand-drawn geometry here is in logical px; the fonts drawn between the elements are DPI-baked,
// so every spinner/bar/gap constant must be multiplied by dpiScale or it renders native-tiny and
// the spacing/centering drifts at HiDPI / font_scale > 1. (Font metrics are already scaled.)
const float dpi = ui::Layout::dpiScale();
// Layout constants
float lineH = ImGui::GetTextLineHeightWithSpacing();
float spinnerR = loadElem("spinner-radius", 18.0f);
float gap = loadElem("vertical-gap", 8.0f);
float barH = loadElem("progress-bar", 6.0f);
float barW = loadElem("progress-width", 260.0f);
float spinnerR = loadElem("spinner-radius", 18.0f) * dpi;
float gap = loadElem("vertical-gap", 8.0f) * dpi;
float barH = loadElem("progress-bar", 6.0f) * dpi;
float barW = loadElem("progress-width", 260.0f) * dpi;
float cx = ws.x * 0.5f; // centre X (local coords)
// Estimate total block height for vertical centering
@@ -5494,8 +5832,8 @@ void App::renderLoadingOverlay(float contentH)
// -------------------------------------------------------------------
{
float r = spinnerR;
float thick = loadElem("spinner-thickness", 2.5f);
ImVec2 sc(wp.x + cx, curY + r + 2.0f);
float thick = loadElem("spinner-thickness", 2.5f) * dpi;
ImVec2 sc(wp.x + cx, curY + r + 2.0f * dpi);
// Background ring (dim)
dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48);
@@ -5508,7 +5846,63 @@ void App::renderLoadingOverlay(float contentH)
dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)",
IM_COL32(255, 218, 0, 200)), 0, thick);
curY += r * 2.0f + gap + 4.0f;
curY += r * 2.0f + gap + 4.0f * dpi;
}
// -------------------------------------------------------------------
// Post-repair rescan — a calm, recovery-aware screen (not the generic "daemon stuck / RPC timeout /
// restart daemon" text). The daemon was intentionally restarted with a full rescan, which
// legitimately takes minutes and won't answer RPC yet; reassure + show elapsed and the growing size.
// -------------------------------------------------------------------
// Only while the daemon is actually alive-and-rescanning. If it EXITED (crashed for a reason distinct
// from the wallet file), fall through to the normal error/stall UI — tryConnect() clears the flag, but
// gate here too so a lingering frame never shows "everything's fine" over a dead daemon.
if (post_recovery_rescan_ &&
!(daemon_controller_ && daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error)) {
if (post_recovery_rescan_since_ <= 0.0) post_recovery_rescan_since_ = ImGui::GetTime();
ImFont* titleF = Type().subtitle1(); if (!titleF) titleF = ImGui::GetFont();
ImFont* capF = Type().caption(); if (!capF) capF = ImGui::GetFont();
auto centeredLine = [&](ImFont* f, ImU32 col, const char* s, float wrapW) {
ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, wrapW, s);
float x = (wrapW > 0.0f) ? (wp.x + cx - wrapW * 0.5f) : (wp.x + cx - ts.x * 0.5f);
dl->AddText(f, f->LegacySize, ImVec2(x, curY), col, s, nullptr, wrapW);
curY += ts.y + gap;
};
centeredLine(titleF, IM_COL32(230, 210, 90, 235), TR("wallet_recovery_rescan_title"), 0.0f);
float wrapW = ws.x * 0.8f; if (wrapW > 620.0f * dpi) wrapW = 620.0f * dpi;
centeredLine(capF, IM_COL32(200, 200, 200, 220), TR("wallet_recovery_rescan_body"), wrapW);
// Elapsed + the growing wallet size — concrete "it's working" feedback while RPC is silent. Recompute
// at most once a second (the display granularity) to avoid a stat()+format on every frame.
int secs = (int)(ImGui::GetTime() - post_recovery_rescan_since_); if (secs < 0) secs = 0;
static int s_lastSec = -1;
static std::string s_elapsed, s_size;
if (secs != s_lastSec) {
s_lastSec = secs;
char clock[16]; snprintf(clock, sizeof(clock), "%d:%02d", secs / 60, secs % 60);
char ebuf[96]; snprintf(ebuf, sizeof(ebuf), TR("wallet_recovery_rescan_elapsed"), clock);
s_elapsed = ebuf;
const std::string walletPath = util::Platform::getDragonXDataDir() + "/" +
((settings_ && !settings_->getActiveWalletFile().empty())
? settings_->getActiveWalletFile() : std::string("wallet.dat"));
uint64_t wsz = util::Platform::getFileSize(walletPath);
if (wsz > 0) { char sbuf[96]; snprintf(sbuf, sizeof(sbuf), TR("wallet_recovery_rescan_size"),
util::Platform::formatFileSize(wsz).c_str()); s_size = sbuf; }
else s_size.clear();
}
curY += gap * 0.5f;
centeredLine(capF, IM_COL32(150, 150, 150, 220), s_elapsed.c_str(), 0.0f);
if (!s_size.empty()) centeredLine(capF, IM_COL32(130, 130, 130, 210), s_size.c_str(), 0.0f);
// Backstop for a genuinely slow (but still running) rescan: after several minutes add a gentle
// "it's safe to leave running / watch the Console" line — never the scary "restart the node".
if (secs > 600) {
curY += gap * 0.5f;
centeredLine(capF, IM_COL32(150, 150, 150, 200), TR("wallet_recovery_rescan_slow"), wrapW);
}
return; // skip the generic status / stall / error / daemon-log sections
}
// -------------------------------------------------------------------
@@ -5518,12 +5912,24 @@ void App::renderLoadingOverlay(float contentH)
const char* statusText = connection_status_.c_str();
ImFont* font = Type().subtitle1();
if (!font) font = ImGui::GetFont();
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
// Short statuses (the common case: "Connected", "Starting daemon") stay centered; long ones
// (a full dir_error path, a libcurl connect error) wrap to a clamped box instead of running
// off both edges of the overlay — the same clamp the daemon-error blocks below use.
float wrapW = ws.x * 0.8f; if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi;
ImVec2 full = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText);
if (full.x <= wrapW) {
dl->AddText(font, font->LegacySize,
ImVec2(wp.x + cx - ts.x * 0.5f, curY),
ImVec2(wp.x + cx - full.x * 0.5f, curY),
IM_COL32(220, 220, 220, 255), statusText);
curY += full.y + gap;
} else {
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, wrapW, statusText);
dl->AddText(font, font->LegacySize,
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
IM_COL32(220, 220, 220, 255), statusText, nullptr, wrapW);
curY += ts.y + gap;
}
}
// -------------------------------------------------------------------
// 2b. Warmup description (subtitle explaining what's happening)
@@ -5546,7 +5952,7 @@ void App::renderLoadingOverlay(float contentH)
float progress = state_.sync.witness_progress;
if (progress < 0.0f) progress = 0.0f;
if (progress > 1.0f) progress = 1.0f;
float barRadius = loadElem("progress-bar", 3.0f);
float barRadius = loadElem("progress-bar", 3.0f) * dpi;
float barX = wp.x + cx - barW * 0.5f;
ImVec2 barMin(barX, curY);
ImVec2 barMax(barX + barW, curY + barH);
@@ -5583,7 +5989,7 @@ void App::renderLoadingOverlay(float contentH)
// -------------------------------------------------------------------
if (state_.connected && state_.sync.syncing) {
float progress = static_cast<float>(state_.sync.verification_progress);
float barRadius = loadElem("progress-bar", 3.0f);
float barRadius = loadElem("progress-bar", 3.0f) * dpi;
float barX = wp.x + cx - barW * 0.5f;
ImVec2 barMin(barX, curY);
@@ -5647,7 +6053,7 @@ void App::renderLoadingOverlay(float contentH)
// Indeterminate progress bar
float encBarW = barW * 0.6f;
float encBarH = 4.0f;
float encBarH = 4.0f * dpi;
float encBarX = wp.x + cx - encBarW * 0.5f;
dl->AddRectFilled(ImVec2(encBarX, curY), ImVec2(encBarX + encBarW, curY + encBarH),
IM_COL32(255, 255, 255, 20), 2.0f);
@@ -5664,7 +6070,8 @@ void App::renderLoadingOverlay(float contentH)
// 3c. Daemon crash error message
// -------------------------------------------------------------------
if (daemon_controller_ &&
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) {
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error &&
!wallet_auto_recovered_) { // a salvage is NOT a crash — it has its own recovery dialog
curY += gap;
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
@@ -5679,35 +6086,11 @@ void App::renderLoadingOverlay(float contentH)
IM_COL32(255, 90, 90, 255), errTitle);
curY += ts.y + gap * 0.5f;
// Wallet auto-recovery/salvage takes over the error card: a concise message + prominent one-click
// actions, RIGHT HERE in the overlay the user is looking at (the separate dialog can be occluded
// by this full-frame overlay while the node is down). Skip the verbose daemon dump so the buttons
// stay on-screen. Same handlers as the dialog.
if (wallet_auto_recovered_) {
const char* msg = TR("wallet_recovered_warn");
float wrapW = ws.x * 0.8f; if (wrapW > 640.0f) wrapW = 640.0f;
ImVec2 ms = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, msg);
dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY),
IM_COL32(230, 210, 210, 235), msg, nullptr, wrapW);
curY += ms.y + gap;
const float dpi = ui::Layout::dpiScale();
const float bw = 340.0f * dpi;
auto placeBtn = [&](const char* label) -> bool {
ImGui::SetCursorScreenPos(ImVec2(wp.x + cx - bw * 0.5f, curY));
const bool clicked = ui::material::TactileButton(label, ImVec2(bw, 0));
curY = ImGui::GetItemRectMax().y + gap * 0.4f;
return clicked;
};
if (walletRebuildAvailable() && placeBtn(TR("wallet_recovered_rebuild"))) rebuildWalletDatabase();
if (placeBtn(TR("wallet_recovered_restore"))) restoreOriginalWallet();
if (placeBtn(TR("wallet_recovered_open_folder")))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
} else {
// Error details (wrapped) — full diagnostic info.
// Error details (wrapped) — full diagnostic info for a genuine (non-recovery) crash.
const std::string& errDetail = daemon_controller_->lastError();
if (!errDetail.empty()) {
float wrapW = ws.x * 0.8f;
if (wrapW > 700.0f) wrapW = 700.0f;
if (wrapW > 700.0f * dpi) wrapW = 700.0f * dpi;
ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
@@ -5724,7 +6107,6 @@ void App::renderLoadingOverlay(float contentH)
curY += hs2.y + gap;
}
}
}
// -------------------------------------------------------------------
// 3d. "Taking longer than expected" notice — the daemon is reachable/launching but
@@ -5757,7 +6139,7 @@ void App::renderLoadingOverlay(float contentH)
snprintf(stallBody, sizeof(stallBody), TR("loading_stall_body"),
(float)(ImGui::GetTime() - connect_stall_since_));
float wrapW = ws.x * 0.8f;
if (wrapW > 640.0f) wrapW = 640.0f;
if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi;
ImVec2 bs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, stallBody);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
@@ -5778,19 +6160,19 @@ void App::renderLoadingOverlay(float contentH)
// -------------------------------------------------------------------
// 4. Daemon output snippet (last few lines, if embedded)
// -------------------------------------------------------------------
if (daemon_controller_) {
if (daemon_controller_ && !wallet_auto_recovered_) { // recovery moves the log behind the dialog's disclosure
auto lines = daemon_controller_->recentLines(8);
if (!lines.empty()) {
curY += gap;
float panelW = ws.x * 0.85f;
if (panelW > 900.0f) panelW = 900.0f;
if (panelW > 900.0f * dpi) panelW = 900.0f * dpi;
float panelX = wp.x + cx - panelW * 0.5f;
float panelPad = 8.0f;
float panelPad = 8.0f * dpi;
ImFont* capFont = Type().caption();
if (!capFont) capFont = ImGui::GetFont();
float panelLineH = capFont->LegacySize + 4.0f;
float panelLineH = capFont->LegacySize + 4.0f * dpi;
float panelContentH = panelPad * 2.0f + panelLineH * (float)lines.size();
ImVec2 panelMin(panelX, curY);

View File

@@ -906,6 +906,20 @@ private:
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
enum class RecoveryPhase { Offer, Working, Done, Failed };
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
bool post_recovery_rescan_ = false;
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
std::mutex wallet_restore_mutex_;

View File

@@ -243,7 +243,7 @@ void App::detectWalletAutoRecovery()
wallet_auto_recovered_ = true;
wallet_auto_recovered_warned_ = true;
show_wallet_recovered_dialog_ = true;
ui::Notifications::instance().error(TR("wallet_recovered_notify"), 30.0f);
ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
}
@@ -408,9 +408,12 @@ void App::tryConnect()
// "stuck connecting" while the node silently died-and-respawned. Surface each new crash once.
const int crashes = daemon_controller_->crashCount();
if (crashes > daemon_last_seen_crashes_) {
daemon_last_seen_crashes_ = crashes;
daemon_last_seen_crashes_ = crashes; // consume it either way, so it can't toast later
const std::string detail = daemon_controller_->lastError();
if (!detail.empty()) {
// Suppress the scary "dragonxd exited unexpectedly" toast during recovery: the stop after a
// salvage, and the intentional restart-with-rescan after a repair, are both EXPECTED here and
// owned by the recovery UI (dialog / calm rescan overlay).
if (!detail.empty() && !wallet_auto_recovered_ && !post_recovery_rescan_) {
connection_status_ = TR("sb_daemon_start_failed");
ui::Notifications::instance().error(detail, 30.0f);
}
@@ -527,6 +530,15 @@ void App::tryConnect()
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
// A repair completed and we restarted with a full rescan, but the fresh daemon has
// now EXITED — a fault distinct from the wallet file (corrupt block index, disk full,
// OOM). Drop out of the calm "finishing repair" state so this surfaces as a normal
// daemon failure (reindex offer / crash toast / restart) instead of silently freezing
// the reconnect loop on a reassuring "don't restart" screen with no way forward.
if (post_recovery_rescan_) {
post_recovery_rescan_ = false;
wallet_auto_recovered_ = false; // repair done; the original-salvage hold is over
}
// If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata
// format mismatch after an update, or a corrupt index), crash-restarting just repeats
// the same abort — and each attempt reloads the whole index (wasteful). Detect it once
@@ -597,6 +609,11 @@ void App::onConnected()
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
// A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card
// suppression and status-chip hold can't persist forever. (Only when we were mid-post-repair-rescan;
// a pre-repair salvaged-daemon connect keeps the flag so the recovery dialog/chip stay available.)
if (post_recovery_rescan_) wallet_auto_recovered_ = false;
post_recovery_rescan_ = false; // repair's post-restart rescan is past the RPC-less phase now
daemon_start_error_shown_ = false;
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected");
@@ -995,10 +1012,13 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
// For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a
// restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 —
// so the single clamped form reproduces the original restore's unclamped bal += amt.
// Debit/restore the SPENDABLE view ONLY. The DISPLAY balances now come from an honest minconf=0 RPC
// that already reflects the outgoing spend AND the incoming 0-conf change, so debiting them here too
// would re-crater the display. This delta only lowers what can be re-spent before the change confirms.
auto applyToAddress = [&](std::vector<AddressInfo>& addresses) {
for (auto& address : addresses) {
if (address.address == fromAddress) {
address.balance = std::max(0.0, address.balance + signedAmount);
address.spendableBalance = std::max(0.0, address.spendableBalance + signedAmount);
return true;
}
}
@@ -1007,11 +1027,12 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
if (includeAggregates) {
if (!fromAddress.empty() && fromAddress[0] == 'z') {
state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount);
state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount);
} else {
state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount);
state_.spendableTransparentBalance = std::max(0.0, state_.spendableTransparentBalance + signedAmount);
}
state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount);
state_.spendableTotalBalance = std::max(0.0, state_.spendableTotalBalance + signedAmount);
state_.unconfirmedBalance = std::max(0.0, state_.totalBalance - state_.spendableTotalBalance);
}
}
@@ -1668,7 +1689,7 @@ void App::refreshCoreData()
// Auto-shield transparent funds if enabled
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing &&
!auto_shield_pending_.exchange(true)) {
std::string targetZAddr;
for (const auto& addr : state_.addresses) {
@@ -1679,7 +1700,7 @@ void App::refreshCoreData()
}
if (!targetZAddr.empty() && worker_) {
DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n",
state_.transparent_balance, targetZAddr.c_str());
state_.spendableTransparentBalance, targetZAddr.c_str());
// Use the user-configured fee, formatted fixed-decimal so the daemon's
// ParseFixedPoint accepts it (a small double would serialize to "5e-05").
const std::string feeStr =
@@ -3029,14 +3050,14 @@ std::string App::chatPayFromZaddr(double fee) const
std::string reply;
if (settings_) reply = settings_->getChatReplyZaddr();
for (const auto& a : state_.z_addresses)
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply;
std::string best;
double bestBal = -1.0;
for (const auto& a : state_.z_addresses)
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) {
best = a.address;
bestBal = a.balance;
bestBal = a.spendableBalance;
}
return best; // empty → no z-address can cover the fee
}
@@ -4575,7 +4596,12 @@ void App::restoreOriginalWallet()
ui::Notifications::instance().warning(TR("wallet_restore_busy"));
return;
}
show_wallet_recovered_dialog_ = false;
// Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the
// honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every
// file-safety step below is unchanged.
show_wallet_recovered_dialog_ = true;
recovery_phase_ = RecoveryPhase::Working;
recovery_last_action_rebuild_ = false;
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true; // gate the reconnect loop while we swap files
connection_status_ = TR("sb_restarting_daemon");
@@ -4684,6 +4710,19 @@ void App::pumpWalletRestore()
if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; }
}
if (!done) return;
// Primary outcome channel: if the recovery dialog is still up (Working), flip it to Done/Failed in
// place with the real result. The toast below stays as the secondary echo for a dismissed/alt-tabbed
// user. sev 2 = failed (op discarded, nothing changed); sev 0/1 = done (1 carries a warning message).
if (show_wallet_recovered_dialog_ && recovery_phase_ == RecoveryPhase::Working) {
recovery_outcome_sev_ = sev;
recovery_outcome_msg_ = msg;
recovery_phase_ = (sev == 2) ? RecoveryPhase::Failed : RecoveryPhase::Done;
// Clean success (sev 0) → the daemon is now restarting with a full rescan. Flag it so the loading
// overlay shows a calm "finishing repair" screen (not the scary generic stall) until it connects.
// NOT for sev 1 (a warning like "node didn't restart") — nothing is rescanning then, and the Done
// dialog already shows that message. Timestamp is stamped on the first overlay frame.
if (sev == 0) { post_recovery_rescan_ = true; post_recovery_rescan_since_ = 0.0; }
}
if (sev == 2) ui::Notifications::instance().error(msg, 25.0f);
else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f);
else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f);
@@ -4705,7 +4744,10 @@ static std::string findWalletRebuildHelper()
const std::string p = d + "/" + exe;
if (fs::exists(p, ec)) return p;
}
return {};
// Not sitting next to the app/daemon — but a self-contained exe carries it embedded. Extract it on
// demand (first-run param extraction is gated on needsParamsExtraction(), so it may never have run
// on a machine that already had the Sapling params). Returns "" on non-embedded builds.
return dragonx::resources::ensureWalletRebuildHelperExtracted();
}
bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); }
@@ -4724,7 +4766,11 @@ void App::rebuildWalletDatabase()
const std::string helper = findWalletRebuildHelper();
if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }
show_wallet_recovered_dialog_ = false;
// Keep the recovery dialog OPEN through the rebuild (Working → Done/Failed in place). Presentation
// only; the run-helper → verify-before-swap → copy/rename-never-delete steps below are unchanged.
show_wallet_recovered_dialog_ = true;
recovery_phase_ = RecoveryPhase::Working;
recovery_last_action_rebuild_ = true;
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true;
connection_status_ = TR("sb_restarting_daemon");
@@ -5020,7 +5066,10 @@ void App::sendTransaction(const std::string& from, const std::string& to,
recipient["address"] = to;
recipient["amount"] = util::formatAmountFixed(amount);
if (!memo.empty()) {
recipient["memo"] = memo;
// The daemon rejects a raw memo — it wants hex or a "utf8:" prefix. Prefix the user's plain-text
// memo so it's UTF-8-encoded into the note (same as the chat path). The unprefixed `memo` is still
// passed to submitZSendMany below for the tx-history display.
recipient["memo"] = "utf8:" + memo;
}
recipients.push_back(recipient);
@@ -5164,7 +5213,7 @@ void App::resendWithFeeGapWorkaround(const std::string& from, const std::string&
nlohmann::json primary;
primary["address"] = to;
primary["amount"] = util::formatAmountFixed(amount);
if (!memo.empty()) primary["memo"] = memo;
if (!memo.empty()) primary["memo"] = "utf8:" + memo; // daemon needs hex or a "utf8:" prefix (see submit)
recipients.push_back(primary);
nlohmann::json selfOut;
selfOut["address"] = from;

View File

@@ -170,10 +170,10 @@ void App::installDemoWalletData()
}
auto zaddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
};
auto taddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
};
state_.z_addresses = {
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),

View File

@@ -19,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
for (size_t i = 0; i < addresses.size(); ++i) {
const auto& address = addresses[i];
if (!address.isSpendable()) continue;
if (requirePositiveBalance && address.balance <= 0.0) continue;
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
indices.push_back(i);
}
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
return addresses[lhs].balance > addresses[rhs].balance;
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
});
return indices;
}
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
int bestIndex = -1;
double bestBalance = 0.0;
for (size_t i = 0; i < addresses.size(); ++i) {
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
bestBalance = addresses[i].balance;
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
bestBalance = addresses[i].spendableBalance;
bestIndex = static_cast<int>(i);
}
}

View File

@@ -21,13 +21,17 @@ namespace dragonx {
*/
struct AddressInfo {
std::string address;
double balance = 0.0;
double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
std::string type; // "shielded" or "transparent"
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
// For display
std::string label;
// CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
// brace-init of the leading fields (used in tests) still compiles.
double spendableBalance = 0.0;
// Derived
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
bool isShielded() const { return type == "shielded"; }
@@ -252,11 +256,17 @@ struct WalletState {
// Sync status
SyncInfo sync;
// Balances (named to match UI usage)
double privateBalance = 0.0; // shielded balance
// Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
// user's own pending change and don't crater during an unconfirmed send.
double privateBalance = 0.0; // shielded balance (display, incl. pending change)
double transparentBalance = 0.0;
double totalBalance = 0.0;
double unconfirmedBalance = 0.0;
double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
// CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
// minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
double spendablePrivateBalance = 0.0;
double spendableTransparentBalance = 0.0;
double spendableTotalBalance = 0.0;
// Aliases for backward compatibility
double& shielded_balance = privateBalance;
@@ -325,6 +335,7 @@ struct WalletState {
sync = SyncInfo{};
privateBalance = transparentBalance = totalBalance = 0.0;
unconfirmedBalance = 0.0;
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
encrypted = false;
locked = false;
unlocked_until = 0;

View File

@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
#endif
#ifdef HAS_EMBEDDED_XMRIG
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
#endif
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
}
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (rebuildRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
if (!extractResource(rebuildRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
// if it's still running, the remove fails harmlessly and we retry on the next startup.
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
return success;
}
std::string ensureWalletRebuildHelperExtracted()
{
#ifdef HAS_EMBEDDED_WALLET_REBUILD
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (!res || res->size == 0) return {};
#ifdef _WIN32
const char sep = '\\';
#else
const char sep = '/';
#endif
const std::string dir = getDaemonDirectory();
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
std::error_code ec;
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
std::filesystem::create_directories(dir, ec);
if (!extractResource(res, dest)) return {};
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
return dest;
#else
return {};
#endif
}
std::string getDaemonDirectory()
{
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or

View File

@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
// caller should stop the daemon first. Returns true if all present resources were written.
bool reextractBundledDaemon();
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
std::string ensureWalletRebuildHelperExtracted();
// Resource names
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";

View File

@@ -37,16 +37,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
{
if (!unspent.is_array()) return;
std::map<std::string, double> balances;
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (address && amount) balances[*address] += *amount;
if (!address || !amount) continue;
total[*address] += *amount;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount;
}
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
for (auto& info : addresses) {
auto balance = balances.find(info.address);
if (balance != balances.end()) info.balance = balance->second;
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
}
}
@@ -249,7 +260,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
{
CoreRefreshResult result;
result.balanceOk = balanceOk && totalBalance.is_object();
@@ -258,6 +269,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total");
}
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
}
result.blockchainOk = blockOk && blockInfo.is_object();
if (result.blockchainOk) {
@@ -274,17 +290,21 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
{
json totalBalance;
json spendableBalance;
json blockInfo;
bool balanceOk = false;
bool blockOk = false;
if (includeBalance) {
try {
totalBalance = rpc.call("z_gettotalbalance", json::array());
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {}
}
try {
@@ -294,7 +314,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
return parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
@@ -611,15 +631,19 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
try {
json unspent = rpc.call("z_listunspent", json::array());
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) {
try {
json balance = rpc.call("z_getbalance", json::array({info.address}));
if (!balance.is_null()) info.balance = balance.get<double>();
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
if (!total.is_null()) info.balance = total.get<double>();
} catch (...) {}
try { // spendable (minconf=1); degrade to the display value on old daemons
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
@@ -631,7 +655,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
try {
json unspent = rpc.call("listunspent", json::array());
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
@@ -1197,6 +1221,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance;
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
state.last_balance_update = updatedAt;
}

View File

@@ -98,9 +98,12 @@ public:
struct CoreRefreshResult {
bool balanceOk = false;
std::optional<double> shieldedBalance;
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
std::optional<double> transparentBalance;
std::optional<double> totalBalance;
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
std::optional<double> spendableTransparentBalance;
std::optional<double> spendableTotalBalance;
bool blockchainOk = false;
std::optional<int> blocks;
std::optional<int> headers;
@@ -227,6 +230,7 @@ public:
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
const nlohmann::json& spendableBalance,
bool balanceOk,
const nlohmann::json& blockInfo,
bool blockOk);

View File

@@ -32,19 +32,22 @@ void Notifications::render()
return v >= 0 ? v : fb;
};
// Status bar geometry
float sbHeight = S.window("components.status-bar").height;
if (sbHeight <= 0.0f) sbHeight = 30.0f;
// Status bar geometry. These are logical-px schema values; the icon/text drawn into the pill
// are DPI-baked, so scale the box by dpiScale to match the (also DPI-scaled) rendered status bar
// and keep the icon/text inside the pill at HiDPI.
const float dp = Layout::dpiScale();
float sbHeight = S.window("components.status-bar").height * dp;
if (sbHeight <= 0.0f) sbHeight = 30.0f * dp;
ImGuiViewport* viewport = ImGui::GetMainViewport();
float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y;
float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f;
// Toast pill sizing — fit inside status bar with margin
float pillMarginY = nde("pill-margin-y", 3.0f);
float pillMarginY = nde("pill-margin-y", 3.0f) * dp;
float pillHeight = sbHeight - pillMarginY * 2.0f;
float pillPadX = nde("padding-x", 12.0f);
float pillRounding = nde("pill-rounding", 12.0f);
float pillPadX = nde("padding-x", 12.0f) * dp;
float pillRounding = nde("pill-rounding", 12.0f) * dp;
// Get accent color based on type — resolved from theme palette
ImVec4 accent_color, text_color;
@@ -89,7 +92,7 @@ void Notifications::render()
ImFont* textFont = material::Type().caption();
ImFont* iconFont = material::Type().iconSmall();
float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f;
float iconGap = 4.0f;
float iconGap = 4.0f * dp;
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
// Clamp to reasonable bounds
@@ -122,7 +125,7 @@ void Notifications::render()
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
// corners. Draw a full-pill-size rounded rect and clip it to just the
// bottom-left progress strip so both bottom corners are respected.
float progH = nde("progress-bar-height", 2.0f);
float progH = nde("progress-bar-height", 2.0f) * dp;
float progW = pillWidth * (1.0f - progress);
if (progW > 0.0f) {
ImVec2 clipMin(pillX, pMax.y - progH);

View File

@@ -2081,12 +2081,22 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, 0);
ImGui::SetCursorPosX(leftX + labelW);
ImGui::AlignTextToFramePadding();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", dirPath.c_str());
// In the two-column layout this shares one draw list with the Daemon-binary
// column (no clip rect between them), so a long OS data-dir path (Windows
// AppData / macOS Application Support) would overrun into it. Middle-ellipsize
// to the remaining column width (room left for the copy button); the full path
// stays available via the tooltip, click-to-open, and the copy button.
ImFont* pathFont = ImGui::GetFont();
const float pathAvailW = contentW - labelW - Layout::spacingSm()
- ImGui::GetFrameHeight() - Layout::spacingXs();
const std::string dirShown =
material::TruncateToWidth(dirPath, pathFont, pathFont->LegacySize, pathAvailW);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", dirShown.c_str());
if (ImGui::IsItemHovered()) {
const ImVec2 tmn = ImGui::GetItemRectMin(), tmx = ImGui::GetItemRectMax();
dl->AddLine(ImVec2(tmn.x, tmx.y), ImVec2(tmx.x, tmx.y), Primary());
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("tt_open_dir"));
material::Tooltip("%s\n%s", dirPath.c_str(), TR("tt_open_dir"));
}
if (ImGui::IsItemClicked()) util::Platform::openFolder(dirPath);
ImGui::SameLine(0, Layout::spacingSm());

View File

@@ -324,12 +324,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
s_dragIdx < (int)rows.size()) {
const auto& srcRow = rows[s_dragIdx];
const auto& dstRow = rows[s_dropTargetIdx];
if (srcRow.info->balance > 1e-9) {
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
AddressTransferDialog::TransferInfo ti;
ti.fromAddr = srcRow.info->address;
ti.toAddr = dstRow.info->address;
ti.fromBalance = srcRow.info->balance;
ti.toBalance = dstRow.info->balance;
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
ti.toBalance = dstRow.info->balance; // destination display only
ti.fromIsZ = srcRow.isZ;
ti.toIsZ = dstRow.isZ;
AddressTransferDialog::show(app, ti);
@@ -820,7 +820,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f) * hs;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());

View File

@@ -137,13 +137,15 @@ static double GetAvailableBalance(App* app) {
// not a stored list index. The index desyncs from s_from_address after an address-list
// refresh, and is left at -1 when the source is chosen from another tab ("Send from this
// address") — which previously made the sufficiency check see 0 and block a valid send.
// CONFIRMED balance only — this is the spend ceiling (Max button, slider, validation, pre-broadcast
// re-check). z_sendmany runs at minconf=1, so offering 0-conf change here would just make the send fail.
if (s_from_address[0] != '\0') {
for (const auto& a : state.addresses) {
if (a.address == s_from_address) return a.balance;
if (a.address == s_from_address) return a.spendableBalance;
}
}
if (s_selected_from_idx >= 0 && s_selected_from_idx < static_cast<int>(state.addresses.size())) {
return state.addresses[s_selected_from_idx].balance;
return state.addresses[s_selected_from_idx].spendableBalance;
}
return 0.0;
}
@@ -252,7 +254,7 @@ static void RenderSourceDropdown(App* app, float width) {
std::string trunc = util::truncateMiddle(addr.address,
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
s_source_preview = buf;
} else {
s_source_preview = TR("send_select_source");
@@ -282,7 +284,7 @@ static void RenderSourceDropdown(App* app, float width) {
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
ImGui::PushID(static_cast<int>(i));
if (ImGui::Selectable(buf, isCurrent)) {
@@ -292,7 +294,7 @@ static void RenderSourceDropdown(App* app, float width) {
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s\nBalance: %.8f %s",
addr.address.c_str(), addr.balance, DRAGONX_TICKER);
addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER);
}
ImGui::PopID();
}
@@ -822,7 +824,17 @@ void RenderSendConfirmPopup(App* app) {
if (s_sending) {
Type().text(TypeStyle::Body2, TR("sending"));
} else {
if (TactileButton(TR("confirm_and_send"), ImVec2(S.button("tabs.send", "confirm-button").width * Layout::dpiScale(), std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size, schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs)), S.resolveFont(S.button("tabs.send", "confirm-button").font))) {
// Size to max(schema width, measured label + padding) so a longer translation (e.g. the
// Russian "Подтвердить и отправить") isn't clipped on this pre-broadcast confirm button.
ImFont* confirmFont = S.resolveFont(S.button("tabs.send", "confirm-button").font);
if (!confirmFont) confirmFont = Type().button();
const float confirmW = std::max(
S.button("tabs.send", "confirm-button").width * Layout::dpiScale(),
confirmFont->CalcTextSizeA(confirmFont->LegacySize, FLT_MAX, 0, TR("confirm_and_send")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * Layout::dpiScale());
const float confirmH = std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size,
schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs);
if (TactileButton(TR("confirm_and_send"), ImVec2(confirmW, confirmH), confirmFont)) {
// Re-validate against LIVE state — the confirm dialog persists across frames, so the
// balance could have dropped or sync (re)started (or the fee bumped total over available)
// since Review. Don't broadcast a now-invalid transaction.

View File

@@ -1194,15 +1194,50 @@ void I18n::loadBuiltinEnglish()
strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while.";
// Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy).
strings_["wallet_recovered_title"] = "Your wallet was auto-recovered";
strings_["wallet_recovered_warn"] = "The node moved your wallet aside and loaded a salvaged copy.";
strings_["wallet_recovered_body"] = "On startup the node decided your wallet.dat looked damaged and recovered it automatically. Your ORIGINAL wallet was NOT deleted — it was renamed to \"wallet.<numbers>.bak\" in your data folder, and a salvaged copy is loaded now.\n\nThe salvaged copy may be incomplete, so the balance shown here could be wrong — don't treat it as final.\n\nThis is often a false alarm caused by leftover database files (e.g. after moving the wallet between machines). To restore your original: quit the wallet, then in the data folder rename the current wallet.dat aside, rename \"wallet.<numbers>.bak\" back to \"wallet.dat\", delete the \"database\" folder and any \"__db.*\" files, and reopen.";
strings_["wallet_recovered_open_folder"] = "Open data folder";
strings_["wallet_recovered_dismiss"] = "Keep salvaged copy";
strings_["wallet_recovered_restore"] = "Restore original wallet";
strings_["wallet_recovered_notify"] = "The node recovered your wallet and moved the original to a .bak — your shown balance may be incomplete. See the prompt to restore it.";
strings_["wallet_recovered_title"] = "Your wallet file needs a quick repair";
strings_["wallet_recovered_safety"] = "Your coins are safe.";
strings_["wallet_recovered_warn"] = "When the app started, it found that your wallet file didn't pass its consistency check — this usually happens after an app update or an unclean shutdown. The app already protected your data: it set the old file aside and loaded a repaired copy so you're not stuck.";
strings_["wallet_recovered_body"] = "Nothing has been deleted. Your original wallet is still saved on your computer as a dated backup file, and every option below only copies or renames files — it never erases one. Your keys are never regenerated, only re-read.\n\nThe repaired copy that's loaded now may be missing a few recent transactions, so your balance can look a little low until you finish below and it re-scans.";
strings_["wallet_recovered_open_folder"] = "Show me the files";
strings_["wallet_recovered_open_folder_sub"] = "Opens the wallet data folder so you can inspect the backup files yourself — nothing is changed.";
strings_["wallet_recovered_dismiss"] = "Not now — keep the repaired copy";
strings_["wallet_recovered_dismiss_sub"] = "No files change. You can reopen this anytime from the status bar; your original stays safely backed up either way.";
strings_["wallet_recovered_restore"] = "Restore the original file instead";
strings_["wallet_recovered_restore_sub"] = "Puts your largest untouched backup back in place, verbatim, then re-scans — slightly faster, but only as complete as that one file was. Your current file is kept as a dated backup either way.";
strings_["wallet_recovered_notify"] = "Your wallet file needed a repair — your original was safely backed up. Open the app to review your options.";
// In-dialog recovery lifecycle (Offer → Working → Done/Failed) + disclosures.
strings_["wallet_recovery_working_label"] = "Working";
strings_["wallet_recovery_done"] = "Done";
strings_["wallet_recovery_other_options"] = "Other options";
strings_["wallet_recovery_details_label"] = "Show technical details";
strings_["wallet_recovery_try_other"] = "Try the other option";
strings_["wallet_recovery_success_body"] = "The app loaded your repaired wallet and is re-scanning to total your balance — this can take a few minutes. It reads every record it can, but on rare damaged files it may recover slightly fewer — or occasionally more — addresses than before. Once the re-scan finishes, check your balance and history look right.";
strings_["wallet_recovery_success_restore"] = "Your largest backup file is back in place and loading now, and the app is re-scanning. It's restored verbatim, so it's exactly as complete as that file was — check your balance once the re-scan finishes.";
strings_["wallet_recovery_failure_title"] = "The repair didn't go through";
strings_["wallet_recovery_failure_body"] = "The repair ran, but its result didn't pass verification (it couldn't be read, or had no addresses), so it was discarded automatically before it ever replaced anything. Your wallet is exactly as it was before you clicked — nothing on disk changed.";
strings_["wallet_recovery_whats_happened"] = "What happened?";
// Choice-card layout: two side-by-side cards (recommended one highlighted) + quiet footer links.
strings_["wallet_recovery_rebuild_card"] = "Repair automatically";
strings_["wallet_recovery_restore_card"] = "Restore original";
strings_["wallet_recovery_rebuild_card_desc"] = "Reads every recoverable record into a clean file, then restarts. The most thorough option.";
strings_["wallet_recovery_restore_card_desc"] = "Puts your largest untouched backup back, verbatim \xE2\x80\x94 only as complete as that file was.";
strings_["wallet_recovery_recommended"] = "RECOMMENDED";
strings_["wallet_recovery_repair_go"] = "Repair";
strings_["wallet_recovery_restore_go"] = "Restore";
strings_["wallet_recovery_notnow_short"] = "Not now";
strings_["wallet_recovery_decide_later"] = "Decide later";
strings_["wallet_recovery_decide_later_tip"] = "Closes this and keeps the copy that's loaded now. Nothing is changed, and you can repair anytime \xE2\x80\x94 the status bar keeps a \xE2\x80\x9CWallet repair available\xE2\x80\x9D link.";
strings_["wallet_recovery_files_label"] = "What happens to my files?";
strings_["wallet_recovery_files_detail"] = "Your original wallet is still here \xE2\x80\x94 the app renamed it to a dated backup (wallet.<numbers>.bak) in your data folder and hasn't deleted anything. \xE2\x80\x9CRepair\xE2\x80\x9D reads every record from your fullest wallet into a brand-new clean file. \xE2\x80\x9CRestore\xE2\x80\x9D copies your largest backup back exactly as it is. The copy loaded right now is kept as a backup too.";
// Post-repair rescan screen (shown while the node restarts + re-scans, before it answers RPC).
strings_["wallet_recovery_rescan_title"] = "Finishing your wallet repair";
strings_["wallet_recovery_rescan_body"] = "Re-scanning the blockchain to rebuild your balance and transaction history. This can take several minutes — you don't need to do anything, and please don't restart the node while it's running.";
strings_["wallet_recovery_rescan_elapsed"] = "Working for %s";
strings_["wallet_recovery_rescan_size"] = "Rebuilt wallet is now %s and still filling in";
strings_["wallet_recovery_rescan_slow"] = "This is taking longer than usual, which is normal for a large wallet. It's safe to leave it running — you can watch progress under Advanced \xE2\x96\xB8 Console.";
strings_["sb_finishing_repair"] = "Finishing wallet repair — re-scanning…";
// One-click "Restore original wallet" flow.
strings_["wallet_restore_started"] = "Restoring your original wallet and restarting the node…";
strings_["wallet_restore_started"] = "Restoring your original file — please don't close this window.";
strings_["wallet_restore_busy"] = "The node is busy restarting — try again in a moment.";
strings_["wallet_restore_ok"] = "Original wallet restored. The node is loading it now.";
strings_["wallet_restore_no_backup"] = "Couldn't find a wallet.<timestamp>.bak to restore. Nothing was changed.";
@@ -1212,8 +1247,9 @@ void I18n::loadBuiltinEnglish()
strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place.";
strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings.";
// One-click "Rebuild wallet database" flow (fixes a BDB-inconsistent wallet that keeps getting salvaged).
strings_["wallet_recovered_rebuild"] = "Rebuild wallet database (recommended)";
strings_["wallet_rebuild_started"] = "Rebuilding your wallet database and restarting the node…";
strings_["wallet_recovered_rebuild"] = "Repair automatically (recommended)";
strings_["wallet_recovered_rebuild_sub"] = "Re-reads every recoverable record from your fullest wallet file and writes a clean new one, then restarts. The most thorough option — your current file is kept as a dated backup either way.";
strings_["wallet_rebuild_started"] = "Repairing your wallet file — please don't close this window.";
strings_["wallet_rebuild_ok"] = "Wallet database rebuilt — the node is loading it and rescanning for your balance.";
strings_["wallet_rebuild_no_helper"] = "The wallet-rebuild helper isn't available in this build. Use Restore, or rebuild manually.";
strings_["wallet_rebuild_no_source"] = "Couldn't find a readable wallet to rebuild. Nothing was changed.";
@@ -1345,7 +1381,7 @@ void I18n::loadBuiltinEnglish()
strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required";
strings_["sb_wallet_needs_recovery"] = "Wallet needs recovery — see the prompt";
strings_["sb_wallet_needs_recovery"] = "Wallet repair available";
// Persistent node-status banner (App::renderNodeStatusBanner).
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";

View File

@@ -139,6 +139,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
state.spendablePrivateBalance = state.privateBalance;
state.spendableTransparentBalance = state.transparentBalance;
state.spendableTotalBalance = state.totalBalance;
}
if (model.hasAddresses) {
@@ -178,6 +184,7 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
} else {
info.balance = 0.0; // notes succeeded and address has no spendable outputs
}
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
if (addr.kind == LiteWalletAppAddressKind::Shielded) {

View File

@@ -934,6 +934,7 @@ void testSpendableFiltering()
addresses.push_back({"zs-low", 2.0, "shielded", true});
addresses.push_back({"R-zero", 0.0, "transparent", true});
addresses.push_back({"R-high", 5.0, "transparent", true});
for (auto& a : addresses) a.spendableBalance = a.balance; // selection now ranks by CONFIRMED balance
EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3);
@@ -1424,10 +1425,10 @@ void testNetworkRefreshRpcCollectors()
});
auto core = Refresh::collectCoreRefreshResult(coreRpc);
EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({
"z_gettotalbalance", "getblockchaininfo"
"z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo" // display (minconf=0) + spendable (minconf=1)
}));
EXPECT_EQ(coreRpc.calls[0].params, json::array());
EXPECT_EQ(coreRpc.calls[1].params, json::array());
EXPECT_EQ(coreRpc.calls[0].params, json::array({0}));
EXPECT_EQ(coreRpc.calls[1].params, json::array({1}));
EXPECT_TRUE(core.balanceOk);
EXPECT_TRUE(core.blockchainOk);
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
@@ -1440,7 +1441,7 @@ void testNetworkRefreshRpcCollectors()
coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
"z_gettotalbalance", "getblockchaininfo"
"z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo"
}));
EXPECT_FALSE(partialCore.balanceOk);
EXPECT_TRUE(partialCore.blockchainOk);
@@ -1566,7 +1567,7 @@ void testNetworkRefreshRpcCollectors()
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"z_getbalance", "getaddressesbyaccount", "listunspent"
"z_getbalance", "z_getbalance", "getaddressesbyaccount", "listunspent" // display (minconf=0) + spendable (minconf=1)
}));
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
@@ -1963,7 +1964,8 @@ void testNetworkRefreshResultModels()
dragonx::WalletState state;
auto core = Refresh::parseCoreRefreshResult(
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, // display (minconf=0)
json{{"private", "1.00000000"}, {"transparent", "0.50000000"}, {"total", "1.50000000"}}, // spendable (minconf=1)
true,
json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75},
{"longestchain", 110}, {"notarized", 90}},
@@ -1972,6 +1974,10 @@ void testNetworkRefreshResultModels()
EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001);
EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001);
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
// Confirmed/spendable stays at minconf=1; unconfirmed = display total - spendable total (the pending change).
EXPECT_NEAR(state.spendablePrivateBalance, 1.0, 0.00000001);
EXPECT_NEAR(state.spendableTotalBalance, 1.5, 0.00000001);
EXPECT_NEAR(state.unconfirmedBalance, 0.25, 0.00000001);
EXPECT_EQ(state.sync.blocks, 100);
EXPECT_EQ(state.sync.headers, 105);
EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100"));