6 Commits

Author SHA1 Message Date
f58d009703 fix(lite): show real block height when synced (was 0)
The lite status bar showed "blocks: 0" once fully synced. The backend's
`syncstatus` only includes synced_blocks/total_blocks WHILE actively scanning;
at rest it returns just {"syncing":"false"}, so the parsed syncedBlocks was 0
and became state.sync.blocks. On the synced refresh path, additionally query the
backend `height` command (wallet last-scanned height, a fast local read) and use
it as the synced height/tip, so the block count is correct at rest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 11:36:58 -05:00
0e2c786ebf fix(lite): welcome "Restore from seed" now prompts for the seed inline
On first run the lite welcome screen's "Restore from seed" button only showed a
hint toast and bounced the user to Settings, dismissing the welcome with no
wallet open — it never prompted for a seed. Add a real restore step to the
welcome wizard: a seed-phrase field + optional birthday height, which calls
beginRestoreWalletAsync() (same server failover as create/open), shows
"Restoring…" progress, then completes (wallet syncs) or surfaces the error to
retry. The seed buffer is wiped on success/Back and in finish().

(The Settings -> Lite -> Restore path already prompted for a seed; this fixes
the first-run welcome path.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 02:11:19 -05:00
d54c7f9e11 fix(lite): "Open data folder" points to the actual lite wallet dir
The lite SilentDragonXLite backend stores its wallet in its own directory
(dirs::data_dir()/silentdragonxlite — %APPDATA%\silentdragonxlite on Windows,
~/.silentdragonxlite on Linux, ~/Library/Application Support/silentdragonxlite on
macOS), NOT the full-node getDragonXDataDir() (…/Hush/DRAGONX). The newly added
lite "Open data folder" button opened the wrong (full-node) directory.

Add Platform::getLiteWalletDataDir() mirroring the backend's get_zcash_data_path
for the "main" chain, and point the lite button at it. The full-node button is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 02:01:40 -05:00
b3251e9244 feat(lite): mirror errors into the lite Console (copyable), not just toasts
Lite send/shield, unlock, and key-import failures were shown only as transient
toasts — impossible to copy. Route them through liteLog() so they also appear in
the lite Console (which has a Copy button), alongside the lifecycle/open/sync
errors the controller already logs:
- send/shield broadcast failures (App broadcast-result delivery)
- wallet unlock failure
- key import failure (controller; logs the error text only, never the key)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 22:59:34 -05:00
c40f4d5815 feat(settings): add an "Open data folder" button (wallet + block data)
Add an explicit button in Settings that opens the wallet/blockchain data
directory (getDragonXDataDir()) in the OS file manager via the existing
Platform::openFolder(). Placed in the full-node connection section (next to the
data-dir path, which was only a subtle clickable link) and in the lite section
(always available). i18n strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:14:52 -05:00
5547ab1cac feat(lite): add "Redownload blocks" (rescan from lite server) to Settings
Add a maintenance option for the lite wallet to re-download and re-scan every
block from the lite server — useful when balances or history look wrong.

- LiteWalletController::startRescan() runs the backend `rescan` command (which
  clears the wallet's synced block cache and re-syncs from its birthday) on a
  detached thread, reusing the existing sync progress/refresh machinery: it
  resets syncDone_ so refreshModel() shows progress again and refreshes data on
  completion. No-op if no wallet is open or a scan is already running.
- scanInProgress() exposes the initial-sync-or-rescan state.
- Settings (lite, open wallet) gains a "Redownload blocks" button behind a
  confirmation modal, disabled while a scan is running. i18n strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 17:05:27 -05:00
7 changed files with 263 additions and 5 deletions

View File

@@ -532,6 +532,10 @@ void App::update()
// Deliver a completed async send/shield result to the waiting send_tab callback.
wallet::LiteBroadcastResult broadcast;
if (lite_wallet_->takeBroadcastResult(broadcast)) {
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI
// shows — transient toasts are easy to miss and impossible to copy.
if (!broadcast.ok)
wallet::liteLog("Send/shield failed: " + broadcast.error);
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
lite_send_callback_ = nullptr;
@@ -2018,6 +2022,11 @@ void App::renderLiteFirstRunPrompt()
static int progress = 0; // # words confirmed in order
static double wrongFlashUntil = 0.0; // brief "not the next word" hint
static bool creating = false; // async create (with failover) in flight
// Restore-from-seed step (step 3). The seed buffer is SECRET — wiped in finish() and on Back.
static char restoreSeed[512] = {};
static int restoreBirthday = 0;
static bool restoring = false; // async restore in flight
static std::string restoreErr;
// The welcome page is only relevant before any wallet exists; once create has started
// (creating) or reached reveal/verify (step>0), keep showing it through to completion even
@@ -2031,6 +2040,10 @@ void App::renderLiteFirstRunPrompt()
auto finish = [&]() {
wallet::secureWipeLiteSecret(seed);
sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreBirthday = 0;
restoring = false;
restoreErr.clear();
words.clear();
chips.clear();
progress = 0;
@@ -2101,9 +2114,7 @@ void App::renderLiteFirstRunPrompt()
}
ImGui::SameLine();
if (ImGui::Button(TR("lite_welcome_restore"), ImVec2(btnW, 0))) {
ui::Notifications::instance().info(TR("lite_welcome_restore_hint"), 8.0f);
setCurrentPage(ui::NavPage::Settings);
finish();
step = 3; // inline seed-entry restore (see step 3 below)
}
ImGui::Spacing();
if (ImGui::Button(TR("lite_welcome_later"),
@@ -2156,7 +2167,7 @@ void App::renderLiteFirstRunPrompt()
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
}
} else { // step == 2
} else if (step == 2) {
// ── Verify: tap the words in order ──────────────────────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted("Confirm your backup");
@@ -2209,6 +2220,76 @@ void App::renderLiteFirstRunPrompt()
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
}
} else if (step == 3) {
// ── Restore from an existing seed phrase ────────────────────────────────
ImGui::PushFont(ui::material::Type().subtitle1());
ImGui::TextUnformatted(TR("lite_restore_title"));
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::TextUnformatted(TR("lite_restore_intro"));
ImGui::PopTextWrapPos();
ImGui::Spacing();
if (restoring) {
// Async restore (with server failover) in flight — driven by pumpAsyncOpen().
ImGui::TextUnformatted("Restoring your wallet\xE2\x80\xA6");
if (lite_wallet_->walletOpen()) {
ui::Notifications::instance().success(TR("lite_restore_ok"), 6.0f);
finish(); // wipes restoreSeed; the wallet then syncs from the lite server
} else if (!lite_wallet_->openInProgress() &&
!lite_wallet_->lastOpenError().empty()) {
restoreErr = lite_wallet_->lastOpenError();
restoring = false; // back to the form so the user can fix and retry
}
} else {
ImGui::TextUnformatted(TR("lite_restore_seed_label"));
ImGui::InputTextMultiline("##LiteRestoreSeed", restoreSeed, sizeof(restoreSeed),
ImVec2(380.0f, ImGui::GetTextLineHeight() * 3.2f));
ImGui::Spacing();
ImGui::TextUnformatted(TR("lite_restore_birthday_label"));
ImGui::SetNextItemWidth(160.0f);
ImGui::InputInt("##LiteRestoreBirthday", &restoreBirthday);
if (restoreBirthday < 0) restoreBirthday = 0;
if (!restoreErr.empty()) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::TextUnformatted(restoreErr.c_str());
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
}
ImGui::Spacing(); ImGui::Spacing();
// Trim surrounding whitespace from the entered seed.
std::string seedTrim(restoreSeed);
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin());
while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back();
ImGui::BeginDisabled(seedTrim.empty());
if (ImGui::Button(TR("lite_restore_btn"), ImVec2(btnW, 0))) {
wallet::LiteWalletRestoreRequest req;
req.seedPhrase = seedTrim;
req.birthday = static_cast<unsigned long long>(std::max(0, restoreBirthday));
req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file
if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) {
restoreErr.clear();
restoring = true;
} else {
restoreErr = lite_wallet_->lastOpenError().empty()
? std::string("Could not start restore")
: lite_wallet_->lastOpenError();
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Back", ImVec2(80, 0))) {
sodium_memzero(restoreSeed, sizeof(restoreSeed));
restoreErr.clear();
step = 0;
}
}
}
ImGui::EndPopup();
}
@@ -2243,7 +2324,10 @@ void App::renderLiteUnlockPrompt()
const bool ok = lite_wallet_->unlockWallet(pass);
sodium_memzero(pass, sizeof(pass));
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
else ui::Notifications::instance().error(TR("lite_unlock_failed"));
else {
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
ui::Notifications::instance().error(TR("lite_unlock_failed"));
}
lite_unlock_prompt_ = false;
ImGui::CloseCurrentPopup();
}

View File

@@ -147,6 +147,7 @@ struct SettingsPageState {
bool confirm_delete_blockchain = false;
bool confirm_rescan = false;
bool confirm_restart_daemon = false;
bool confirm_lite_redownload = false;
effects::ScrollFadeShader fade_shader;
};
@@ -1651,6 +1652,16 @@ void RenderSettingsPage(App* app) {
}
}
// Open the lite wallet data folder in the OS file manager (always available in
// lite). The lite backend stores its wallet in its OWN dir (silentdragonxlite),
// NOT the full-node getDragonXDataDir().
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getLiteWalletDataDir());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_open_data_dir"));
// ---- Backup & keys (open wallet only) ----------------------------------
if (app->liteWallet() && app->liteWallet()->walletOpen()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
@@ -1819,6 +1830,24 @@ void RenderSettingsPage(App* app) {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
s_settingsState.lite_encryption_status.c_str());
}
// ---- Maintenance: re-download blocks from the lite server ----
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().text(TypeStyle::Body2, TR("lite_maintenance"));
const bool scanning = app->liteWallet()->scanInProgress();
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
ImGui::BeginDisabled(scanning);
if (TactileButton(TR("lite_redownload_blocks"), ImVec2(0, 0), S.resolveFont("button"))) {
s_settingsState.confirm_lite_redownload = true;
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s", TR("tt_lite_redownload"));
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
scanning ? TR("lite_redownload_running") : TR("lite_redownload_desc"));
} else if (!s_settingsState.lite_export_secret.empty()) {
// Wallet closed while a backup/secret was revealed — don't leave it in memory.
wallet::secureWipeLiteSecret(s_settingsState.lite_export_secret);
@@ -1898,6 +1927,13 @@ void RenderSettingsPage(App* app) {
}
}
// Explicit button to open the wallet + blockchain data folder in the OS file manager.
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TR("settings_open_data_dir"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openFolder(util::Platform::getDragonXDataDir());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_open_data_dir"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// RPC connection — two columns: (Host | Username) and (Port | Password)
@@ -2640,6 +2676,36 @@ void RenderSettingsPage(App* app) {
}
}
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
if (s_settingsState.confirm_lite_redownload) {
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), ICON_MD_WARNING);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_lite_redownload_safe"));
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(TrId("cancel", "lite_redl_cancel").c_str(), ImVec2(btnW, 40))) {
s_settingsState.confirm_lite_redownload = false;
}
ImGui::SameLine();
if (ImGui::Button(TrId("lite_redownload_blocks", "lite_redl_confirm").c_str(), ImVec2(btnW, 40))) {
if (auto* lite = app->liteWallet()) lite->startRescan();
s_settingsState.confirm_lite_redownload = false;
}
EndOverlayDialog();
}
}
}
} // namespace ui

View File

@@ -401,6 +401,8 @@ void I18n::loadBuiltinEnglish()
strings_["bootstrap_restart_daemon"] = "Restart Daemon";
strings_["bootstrap_failed"] = "Bootstrap Failed";
strings_["tt_open_dir"] = "Click to open in file explorer";
strings_["settings_open_data_dir"] = "Open data folder";
strings_["tt_open_data_dir"] = "Open the folder with your wallet and blockchain data in the file manager";
strings_["tt_rpc_host"] = "Hostname of the DragonX daemon";
strings_["tt_rpc_user"] = "RPC authentication username";
strings_["tt_rpc_port"] = "Port for daemon RPC connections";
@@ -418,6 +420,14 @@ void I18n::loadBuiltinEnglish()
strings_["confirm_rescan_safe"] = "Your wallet.dat and blockchain data are not deleted — only re-scanned.";
strings_["confirm_restart_daemon_title"] = "Restart Daemon";
strings_["confirm_restart_daemon_msg"] = "This stops and restarts the daemon to apply the changed options. The wallet will briefly disconnect and reconnect.";
strings_["lite_maintenance"] = "Maintenance";
strings_["lite_redownload_blocks"] = "Redownload blocks";
strings_["lite_redownload_desc"] = "Re-fetch all blocks from the lite server (use if your balance or history looks wrong).";
strings_["lite_redownload_running"] = "Re-downloading blocks…";
strings_["tt_lite_redownload"] = "Re-download and re-scan all blocks from the lite server";
strings_["confirm_lite_redownload_title"] = "Redownload Blocks";
strings_["confirm_lite_redownload_msg"] = "This clears the wallet's downloaded block data and re-fetches and re-scans every block from the lite server. It can take a while; the wallet shows sync progress until it finishes.";
strings_["confirm_lite_redownload_safe"] = "Your wallet, keys, and seed are not affected — only the block data is re-downloaded.";
strings_["tt_encrypt"] = "Encrypt wallet.dat with a passphrase";
strings_["tt_change_pass"] = "Change the wallet encryption passphrase";
strings_["tt_lock"] = "Lock the wallet immediately";
@@ -645,6 +655,12 @@ void I18n::loadBuiltinEnglish()
strings_["lite_welcome_created"] = "Wallet created — back up your recovery phrase now in Settings → Backup & keys";
strings_["lite_welcome_restore_hint"] = "Restore your wallet under Settings → Lite wallet request";
strings_["lite_welcome_create_failed"] = "Could not create wallet";
strings_["lite_restore_title"] = "Restore from seed phrase";
strings_["lite_restore_intro"] = "Enter your recovery seed phrase. The wallet will be restored and then synced from the lite server.";
strings_["lite_restore_seed_label"] = "Seed phrase (words separated by spaces)";
strings_["lite_restore_birthday_label"] = "Birthday block (optional — 0 scans from the start, slower)";
strings_["lite_restore_btn"] = "Restore wallet";
strings_["lite_restore_ok"] = "Wallet restored — syncing from the lite server…";
// Lite send-time unlock prompt.
strings_["lite_unlock_title"] = "Unlock wallet";
strings_["lite_unlock_msg"] = "Enter your passphrase to unlock the wallet for spending.";

View File

@@ -219,6 +219,24 @@ std::string Platform::getDataDir()
return getDragonXDataDir();
}
std::string Platform::getLiteWalletDataDir()
{
// Mirror the SilentDragonXLite backend's get_zcash_data_path() for the "main" chain:
// Windows/macOS: dirs::data_dir()/silentdragonxlite (data_dir = %APPDATA%, ~/Library/App Support)
// Linux: ~/.silentdragonxlite
#ifdef _WIN32
char path[MAX_PATH];
if (SUCCEEDED(SHGetFolderPathA(nullptr, CSIDL_APPDATA, nullptr, 0, path))) {
return std::string(path) + "\\silentdragonxlite\\";
}
return getHomeDir() + "\\AppData\\Roaming\\silentdragonxlite\\";
#elif defined(__APPLE__)
return getHomeDir() + "/Library/Application Support/silentdragonxlite/";
#else
return getHomeDir() + "/.silentdragonxlite/";
#endif
}
std::string Platform::getConfigDir()
{
#ifdef _WIN32

View File

@@ -62,6 +62,15 @@ public:
*/
static std::string getDataDir();
/**
* @brief Get the LITE wallet data directory (where the SilentDragonXLite backend stores
* silentdragonxlite-wallet.dat). Mirrors the backend's get_zcash_data_path() for the
* "main" chain: dirs::data_dir()/silentdragonxlite on Windows/macOS, ~/.silentdragonxlite
* on Linux. Distinct from getDragonXDataDir() (the full-node blocks/wallet dir).
* @return Path like ~/.silentdragonxlite/ or %APPDATA%\silentdragonxlite\
*/
static std::string getLiteWalletDataDir();
/**
* @brief Get the config directory for storing wallet exports/backups
* @return Path like ~/.config/ObsidianDragon/ or %APPDATA%\ObsidianDragon\

View File

@@ -605,6 +605,38 @@ void LiteWalletController::startSync()
});
}
bool LiteWalletController::startRescan()
{
if (!walletOpen_.load()) return false;
// Refuse if a sync/rescan is already running (would race two scans on the backend wallet lock).
if (!syncDone_ || !syncDone_->load()) return false;
// Reset the done flag so refreshModel() re-enters its "scanning, publish progress only" path
// and the UI shows progress again; clearing it BEFORE launching the thread avoids a window
// where the worker would query balances mid-rescan.
syncDone_->store(false);
syncStarted_ = true;
liteLog("Block re-download (rescan) started");
// The previous sync/rescan thread has finished (syncDone_ was true above); detach it before
// reassigning syncThread_. Like startSync's thread it captures shared refs (bridge_ + syncDone_),
// never `this`, so detaching is safe.
if (syncThread_.joinable()) syncThread_.detach();
auto bridge = bridge_;
auto done = syncDone_;
syncThread_ = std::thread([bridge, done] {
if (bridge) {
// `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the
// birthday height — a blocking, uninterruptible full scan, same as `sync`.
bridge->execute("rescan", "");
bridge->execute("save", ""); // backend doesn't auto-save after a rescan
}
done->store(true);
});
return true;
}
std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
{
if (!walletOpen_.load()) return std::nullopt;
@@ -655,6 +687,29 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
if (!mapped.ok) return std::nullopt;
auto model = mapped.model;
applyEncryption(model);
// `syncstatus` only reports synced_blocks/total_blocks WHILE actively scanning; once idle it
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
// surface it as the synced height/tip so the block count is correct at rest.
if (bridge_) {
const auto h = bridge_->execute("height", "");
if (h.ok) {
try {
const auto j = nlohmann::json::parse(h.value);
if (j.is_object() && j.contains("height") && j["height"].is_number_unsigned()) {
const unsigned long long height = j["height"].get<unsigned long long>();
if (height > 0) {
model.hasSyncStatus = true;
model.sync.walletHeight = height;
model.sync.chainHeight = height; // synced: wallet height == chain tip
model.sync.complete = true;
model.sync.progress = 1.0;
}
}
} catch (...) { /* leave the mapped sync fields as-is */ }
}
}
return model;
}
@@ -864,6 +919,7 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
LiteImportResult alt = runImport(transparentFirst ? "import" : "timport");
if (alt.ok) out = alt;
}
if (!out.ok) liteLog("Key import failed: " + out.error); // error text only — never the key
secureWipeLiteSecret(spendingOrViewingKey); // wipe our copy after both attempts
return out;
}

View File

@@ -220,6 +220,15 @@ public:
// op produces a ready wallet; safe to call once.
void startSync();
// Re-download and re-scan every block from the lite server: runs the backend `rescan`
// command (which clears the wallet's synced block cache and re-syncs from its birthday
// height) on a detached thread, reusing the sync progress + refresh machinery. No-op if no
// wallet is open or a scan is already running. True if a rescan was actually started.
bool startRescan();
// True while the initial sync OR a rescan is actively scanning (not yet complete).
bool scanInProgress() const { return syncStarted_ && !(syncDone_ && syncDone_->load()); }
// Generate a new address (shielded if true, else transparent) via the backend. Fast (local
// key derivation), safe to call on the UI thread; the next refresh lists the new address.
LiteNewAddressResult newAddress(bool shielded);