fix: Tier-2 mediums — input trims, confirmations, amount normalization

More robustness fixes from the audit (the import-key pattern, lower severity):

- Trim pasted input before use/validation: network add-server URL/label,
  lite-wallet key import (+ block empty), validate-address, address-book entry.
- Confirmations for destructive/irreversible actions:
  - Address-book Delete now needs a confirming second click (no undo).
  - Console `stop` needs a confirming second `stop` (it shuts down the node).
  - Wizard "Skip" encryption needs a confirming second click (keys stored
    unencrypted).
- Send amount: normalize to 8dp (satoshi precision) on both the DRGX and USD
  inputs so digits past 8dp aren't silently dropped between the preview/review
  and what's actually sent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 13:18:57 -05:00
parent 129a8e6449
commit 65bb98cd09
7 changed files with 75 additions and 20 deletions

View File

@@ -1356,16 +1356,24 @@ void App::renderFirstRunWizard() {
ImGui::SetCursorScreenPos(ImVec2(bx + encBtnW + 12.0f * dp, cy)); ImGui::SetCursorScreenPos(ImVec2(bx + encBtnW + 12.0f * dp, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Skip##enc", ImVec2(skipW2, btnH2))) { if (ui::material::TactileButton("Skip##enc", ImVec2(skipW2, btnH2))) {
wizard_phase_ = WizardPhase::Done; static bool s_skipEncConfirm = false;
settings_->setWizardCompleted(true); if (!s_skipEncConfirm) {
settings_->save(); // Skipping stores private keys UNENCRYPTED — require a confirming second click.
if (!isEmbeddedDaemonRunning() && isUsingEmbeddedDaemon()) { s_skipEncConfirm = true;
if (!startEmbeddedDaemon()) { encrypt_status_ = "Continue WITHOUT encryption? Keys will be stored unencrypted — click Skip again to confirm.";
ui::Notifications::instance().warning( } else {
TR("wizard_daemon_start_failed")); s_skipEncConfirm = false;
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
if (!isEmbeddedDaemonRunning() && isUsingEmbeddedDaemon()) {
if (!startEmbeddedDaemon()) {
ui::Notifications::instance().warning(
TR("wizard_daemon_start_failed"));
}
} }
tryConnect();
} }
tryConnect();
} }
ImGui::PopStyleVar(); ImGui::PopStyleVar();
cy += btnH2; cy += btnH2;

View File

@@ -1741,10 +1741,17 @@ void RenderSettingsPage(App* app) {
ImGuiInputTextFlags_Password); ImGuiInputTextFlags_Password);
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y)); ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
if (TactileButton(TrId("lite_import", "LiteImportKeyBtn").c_str(), ImVec2(0, 0), S.resolveFont("button"))) { if (TactileButton(TrId("lite_import", "LiteImportKeyBtn").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
const auto r = app->liteWallet()->importKey(s_settingsState.lite_import_key); std::string liteKey(s_settingsState.lite_import_key);
sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key)); while (!liteKey.empty() && (liteKey.front()==' '||liteKey.front()=='\t'||liteKey.front()=='\n'||liteKey.front()=='\r')) liteKey.erase(liteKey.begin());
s_settingsState.lite_backup_status = while (!liteKey.empty() && (liteKey.back()==' '||liteKey.back()=='\t'||liteKey.back()=='\n'||liteKey.back()=='\r')) liteKey.pop_back();
r.ok ? TR("lite_key_imported") : r.error; if (liteKey.empty()) {
s_settingsState.lite_backup_status = "Enter a private key to import.";
} else {
const auto r = app->liteWallet()->importKey(liteKey);
sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key));
s_settingsState.lite_backup_status =
r.ok ? TR("lite_key_imported") : r.error;
}
} }
if (!s_settingsState.lite_backup_status.empty()) { if (!s_settingsState.lite_backup_status.empty()) {

View File

@@ -129,7 +129,13 @@ void AddressBookDialog::render(App* app)
const char* primaryLabel = isEdit ? TR("save") : TR("add"); const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes); // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
auto trimAB = [](std::string s) {
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
if (isEdit) { if (isEdit) {
if (getAddressBook().updateEntry(s_selected_index, entry)) { if (getAddressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated")); Notifications::instance().success(TR("address_book_updated"));
@@ -183,11 +189,19 @@ void AddressBookDialog::render(App* app)
ImGui::SameLine(); ImGui::SameLine();
static int s_confirmDeleteIdx = -1;
if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) { if (has_selection) {
book.removeEntry(s_selected_index); if (s_confirmDeleteIdx == s_selected_index) {
s_selected_index = -1; book.removeEntry(s_selected_index);
Notifications::instance().success(TR("address_book_deleted")); s_selected_index = -1;
s_confirmDeleteIdx = -1;
Notifications::instance().success(TR("address_book_deleted"));
} else {
// Require a second click to confirm (no undo for a removed contact).
s_confirmDeleteIdx = s_selected_index;
Notifications::instance().warning("Click Delete again to remove this entry.");
}
} }
} }

View File

@@ -1298,6 +1298,9 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
std::transform(first.begin(), first.end(), first.begin(), std::transform(first.begin(), first.end(), first.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); }); [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
} }
// 'stop' shuts down the node — require a confirming second 'stop'; any other command clears the pending state.
static bool stopConfirmPending = false;
if (first != "stop") stopConfirmPending = false;
auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); }; auto add = [this](const std::string& l, ConsoleChannel c) { addLine(l, c); };
if (first == "clear" || first == "cls") { if (first == "clear" || first == "cls") {
// View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history). // View-only clear — NEVER forwarded (the lite backend's `clear` wipes tx history).
@@ -1307,6 +1310,16 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
exec.printHelp(add); exec.printHelp(add);
} else if (first == "quit" || first == "exit") { } else if (first == "quit" || first == "exit") {
addLine(TR("console_quit_note"), ConsoleChannel::Info); addLine(TR("console_quit_note"), ConsoleChannel::Info);
} else if (first == "stop") {
if (!stopConfirmPending) {
stopConfirmPending = true;
addLine("'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm.",
ConsoleChannel::Warning);
} else {
stopConfirmPending = false;
if (!exec.isReady()) addLine(TR("console_not_connected"), ConsoleChannel::Error);
else exec.submit(cmd);
}
} else if (!exec.isReady()) { } else if (!exec.isReady()) {
addLine(TR("console_not_connected"), ConsoleChannel::Error); addLine(TR("console_not_connected"), ConsoleChannel::Error);
} else { } else {

View File

@@ -178,7 +178,13 @@ void RenderLiteNetworkTab(App* app)
ImGui::InputTextWithHint("##LiteAddLabel", TR("lite_net_add_label_hint"), s_addLabel, sizeof(s_addLabel)); ImGui::InputTextWithHint("##LiteAddLabel", TR("lite_net_add_label_hint"), s_addLabel, sizeof(s_addLabel));
ImGui::SameLine(); ImGui::SameLine();
if (TactileButton(TR("lite_net_add"), ImVec2(addBtnW, 0))) { if (TactileButton(TR("lite_net_add"), ImVec2(addBtnW, 0))) {
std::string url = s_addUrl; auto trimStr = [](std::string s) {
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
std::string url = trimStr(s_addUrl);
std::string addLabel = trimStr(s_addLabel);
if (!wallet::isLiteServerUrlUsable(url)) { if (!wallet::isLiteServerUrlUsable(url)) {
s_addError = TR("lite_net_invalid_url"); s_addError = TR("lite_net_invalid_url");
} else { } else {
@@ -188,7 +194,7 @@ void RenderLiteNetworkTab(App* app)
if (!exists) { if (!exists) {
config::Settings::LiteServerPreference p; config::Settings::LiteServerPreference p;
p.url = url; p.url = url;
p.label = s_addLabel[0] ? std::string(s_addLabel) : url; p.label = !addLabel.empty() ? addLabel : url;
p.enabled = true; p.enabled = true;
servers.push_back(p); servers.push_back(p);
st->setLiteServers(servers); st->setLiteServers(servers);

View File

@@ -1322,7 +1322,8 @@ void RenderSendTab(App* app)
// USD input mode — no step buttons (step=0) // USD input mode — no step buttons (step=0)
ImGui::PushItemWidth(amtInputW); ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) { if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
s_amount = s_usd_amount / market.price_usd; // Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
} }
// Draw DRGX equivalent inside the input field (right-aligned overlay) // Draw DRGX equivalent inside the input field (right-aligned overlay)
{ {
@@ -1343,6 +1344,9 @@ void RenderSendTab(App* app)
// DRGX input mode — no step buttons (step=0) // DRGX input mode — no step buttons (step=0)
ImGui::PushItemWidth(amtInputW); ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##Amount", &s_amount, 0, 0, "%.8f")) { if (ImGui::InputDouble("##Amount", &s_amount, 0, 0, "%.8f")) {
// Normalize to 8dp so digits past satoshi precision aren't silently dropped at send.
s_amount = std::round(s_amount * 1e8) / 1e8;
if (s_amount < 0) s_amount = 0;
if (market.price_usd > 0) if (market.price_usd > 0)
s_usd_amount = s_amount * market.price_usd; s_usd_amount = s_amount * market.price_usd;
} }

View File

@@ -80,7 +80,10 @@ void ValidateAddressDialog::render(App* app)
s_error_message.clear(); s_error_message.clear();
std::string address(s_address_input); std::string address(s_address_input);
// Trim whitespace/newlines a pasted address often carries (the daemon would reject it).
while (!address.empty() && (address.front()==' '||address.front()=='\t'||address.front()=='\n'||address.front()=='\r')) address.erase(address.begin());
while (!address.empty() && (address.back()==' '||address.back()=='\t'||address.back()=='\n'||address.back()=='\r')) address.pop_back();
// Determine if z-address or t-address // Determine if z-address or t-address
bool is_zaddr = !address.empty() && address[0] == 'z'; bool is_zaddr = !address.empty() && address[0] == 'z';