From d52d3d1b7f5d4d05064a9c0dd05bb7282b1e182f Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 5 Jun 2026 18:22:15 -0500 Subject: [PATCH] feat(lite): send-time unlock prompt for locked encrypted wallets When the user confirms a send on a locked encrypted lite wallet, show an unlock modal (passphrase -> unlockWallet) instead of letting the backend reject it with "Wallet is locked". After unlocking, the user re-confirms the send (the form is preserved). Balances remain viewable while locked; only spending needs unlock. - send_tab: the Confirm-and-send button routes to App::requestLiteUnlock() when getWalletState().isLocked(), else sends as before. - App::renderLiteUnlockPrompt(): centered modal, passphrase (Enter submits), Unlock/Cancel; the passphrase buffer is sodium-zeroed after every path. Full-node unaffected (gated on liteWallet()/isLocked()). Builds clean, launches clean, tests pass. Co-Authored-By: Claude Opus 4.8 --- src/app.cpp | 47 +++++++++++++++++++++++++++++++++++++ src/app.h | 5 ++++ src/ui/windows/send_tab.cpp | 6 +++++ src/util/i18n.cpp | 6 +++++ 4 files changed, 64 insertions(+) diff --git a/src/app.cpp b/src/app.cpp index 043c1dc..7a675f1 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -13,6 +13,8 @@ #include "config/settings.h" #include "wallet/lite_wallet_controller.h" #include "wallet/lite_wallet_server_selection_adapter.h" + +#include // sodium_memzero for the lite unlock-passphrase buffer #include "daemon/daemon_controller.h" #include "daemon/embedded_daemon.h" #include "daemon/lifecycle_adapters.h" @@ -1397,6 +1399,8 @@ void App::render() // Lite first-run welcome: prompt to create/restore when no wallet file exists yet. renderLiteFirstRunPrompt(); + // Lite send-time unlock prompt (shown when a spend is attempted on a locked wallet). + renderLiteUnlockPrompt(); if (show_import_key_) { renderImportKeyDialog(); @@ -1843,6 +1847,49 @@ void App::renderLiteFirstRunPrompt() } } +void App::renderLiteUnlockPrompt() +{ + if (!lite_wallet_) return; + static char pass[128] = ""; + + if (lite_unlock_prompt_ && !ImGui::IsPopupOpen("##LiteUnlock")) { + ImGui::OpenPopup("##LiteUnlock"); + } + ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f), + ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + if (ImGui::BeginPopupModal("##LiteUnlock", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + ImGui::PushFont(ui::material::Type().subtitle1()); + ImGui::TextUnformatted(TR("lite_unlock_title")); + ImGui::PopFont(); + ImGui::Spacing(); + ImGui::TextUnformatted(TR("lite_unlock_msg")); + ImGui::Spacing(); + ImGui::SetNextItemWidth(280.0f); + const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass), + ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::Spacing(); + const float btnW = 130.0f; + bool doUnlock = ImGui::Button(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered; + if (doUnlock) { + 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")); + lite_unlock_prompt_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button(TR("cancel"), ImVec2(btnW, 0))) { + sodium_memzero(pass, sizeof(pass)); + lite_unlock_prompt_ = false; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } +} + void App::renderAboutDialog() { auto dlg = ui::schema::UI().drawElement("inline-dialogs", "about"); diff --git a/src/app.h b/src/app.h index acf68e9..2459838 100644 --- a/src/app.h +++ b/src/app.h @@ -157,6 +157,8 @@ public: config::Settings* settings() { return settings_.get(); } // Lite wallet controller (non-null only in lite builds with a linked backend). wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); } + // Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet). + void requestLiteUnlock() { lite_unlock_prompt_ = true; } // (Re)build the lite controller from current settings so a changed lite-server selection // takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp). void rebuildLiteWallet(); @@ -427,6 +429,8 @@ private: bool lite_autoopen_done_ = false; // Lite first-run welcome prompt: dismissed for the session once the user picks an action. bool lite_firstrun_dismissed_ = false; + // Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked. + bool lite_unlock_prompt_ = false; std::unique_ptr daemon_controller_; std::unique_ptr xmrig_manager_; util::AsyncTaskManager async_tasks_; @@ -651,6 +655,7 @@ private: void renderStatusBar(); void renderAboutDialog(); void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet + void renderLiteUnlockPrompt(); // lite-only send-time unlock modal void renderImportKeyDialog(); void renderExportKeyDialog(); void renderBackupDialog(); diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index f103137..2106dcb 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -771,6 +771,11 @@ void RenderSendConfirmPopup(App* app) { Type().text(TypeStyle::Body2, TR("sending")); } else { if (TactileButton(TR("confirm_and_send"), ImVec2(S.button("tabs.send", "confirm-button").width, 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))) { + // A locked encrypted lite wallet can't spend; prompt to unlock instead of sending + // (the backend would otherwise reject with "Wallet is locked"). + if (app->liteWallet() && app->getWalletState().isLocked()) { + app->requestLiteUnlock(); + } else { s_sending = true; s_send_start_time = ImGui::GetTime(); s_tx_status = std::string(TR("sending")) + "..."; @@ -804,6 +809,7 @@ void RenderSendConfirmPopup(App* app) { } ); s_show_confirm = false; + } } ImGui::SameLine(); if (TactileButton(TR("cancel"), ImVec2(S.button("tabs.send", "cancel-button").width, 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", "cancel-button").font))) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index eab4601..b65f68a 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -588,6 +588,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"; + // Lite send-time unlock prompt. + strings_["lite_unlock_title"] = "Unlock wallet"; + strings_["lite_unlock_msg"] = "Enter your passphrase to unlock the wallet for spending."; + strings_["lite_unlock_btn"] = "Unlock"; + strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now"; + strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?"; // Send Tab strings_["pay_from"] = "Pay From";