feat(settings): redesign the Import Key dialog

Rework the full-node Import Key dialog into a safer, Material-consistent, more
capable flow:

- Security: the key is masked by default (Password) with a reveal (eye) toggle,
  a DialogWarningHeader "only import a key you own" note, and the buffer is
  wiped with sodium_memzero on close and on successful import.
- Progress: import triggers a blocking full-chain rescan; the dialog now shows
  "Importing & rescanning — this can take several minutes" with LoadingDots and
  disables Import while it runs (no double-submit). Offline shows a "connect a
  running node" note and disables Import up front.
- Viewing keys: the classifier gains isViewingKey / isRecognizedImportKey;
  importPrivateKey auto-detects a shielded viewing key (zxviews…) and routes to
  z_importviewingkey (watch-only) vs z_importkey / importprivkey. The live type
  indicator shows Transparent / Shielded spending / Shielded viewing (watch-only).
- Result: on success the imported address is captured from the RPC result and
  shown via AddressCopyField.
- Material restyle (OverlayDialogSpec/BlurFloat + TactileButton + Esc) and full
  i18n (12 new keys x 8 languages; CJK subset rebuilt).

Verified: rendered on dark + light skins; a seeded zxviews… key shows the masked
field + "Shielded viewing key (watch-only)" indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 12:58:36 -05:00
parent 8293f02e36
commit 46f3001360
15 changed files with 274 additions and 88 deletions

View File

@@ -66,6 +66,7 @@
#include "ui/material/type.h"
#include "ui/material/typography.h"
#include "ui/material/draw_helpers.h"
#include "ui/widgets/copy_field.h"
#include "ui/notifications.h"
#include "util/i18n.h"
#include "util/platform.h"
@@ -2755,106 +2756,151 @@ void App::renderLiteUnlockPrompt()
void App::renderImportKeyDialog()
{
auto dlg = ui::schema::UI().drawElement("inline-dialogs", "import-key");
auto dlgF = [&](const char* key, float fb) -> float {
auto it = dlg.extraFloats.find(key);
return it != dlg.extraFloats.end() ? it->second : fb;
};
int btnFont = (int)dlgF("button-font", 1);
float btnW = dlgF("button-width", 120.0f);
if (!ui::material::BeginOverlayDialog("Import Private Key", &show_import_key_, dlgF("width", 500.0f), 0.94f)) {
return;
namespace m = ui::material;
float dp = ui::Layout::dpiScale();
m::OverlayDialogSpec ov;
ov.title = TR("import_key_title");
ov.p_open = &show_import_key_;
ov.style = m::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f;
ov.idSuffix = "importkey";
if (!m::BeginOverlayDialog(ov)) return;
// Fresh dialog on each open (the key buffer was already wiped on the last close).
if (ImGui::IsWindowAppearing()) {
import_success_ = false;
import_status_.clear();
import_result_address_.clear();
import_key_reveal_ = false;
}
ImGui::TextWrapped("Enter a private key to import. The wallet will rescan the blockchain for transactions.");
// Esc dismisses (this overlay has no built-in Esc handling).
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) show_import_key_ = false;
// Handle-with-care note.
m::DialogWarningHeader(TR("import_key_warn"));
ImGui::Spacing();
ImGui::Text("Private Key:");
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_));
// Paste & Clear buttons
if (ui::material::StyledButton(TR("paste"), ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
// A running node is required — say so up front instead of failing only after Import.
if (!state_.connected) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
ImGui::Spacing();
}
// Masked key field with a reveal (eye) toggle + Paste/Clear.
ImGui::TextUnformatted(TR("import_key_field"));
float eyeW = ImGui::GetFrameHeight();
ImGui::SetNextItemWidth(-(eyeW + ui::Layout::spacingSm()));
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
ImGui::InputText("##importkey", import_key_input_, sizeof(import_key_input_), flags);
ImGui::SameLine(0, ui::Layout::spacingSm());
if (m::TactileButton(import_key_reveal_ ? ICON_MD_VISIBILITY_OFF : ICON_MD_VISIBILITY,
ImVec2(eyeW, eyeW), m::Type().iconSmall()))
import_key_reveal_ = !import_key_reveal_;
if (ImGui::IsItemHovered()) m::Tooltip("%s", TR("import_key_reveal_tip"));
if (m::TactileButton(TR("paste"))) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) {
snprintf(import_key_input_, sizeof(import_key_input_), "%s", clipboard);
// Trim whitespace
std::string trimmed(import_key_input_);
while (!trimmed.empty() && (trimmed.front() == ' ' || trimmed.front() == '\t' ||
trimmed.front() == '\n' || trimmed.front() == '\r'))
trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\t' ||
trimmed.back() == '\n' || trimmed.back() == '\r'))
trimmed.pop_back();
std::string trimmed(clipboard);
while (!trimmed.empty() && (trimmed.front()==' '||trimmed.front()=='\t'||trimmed.front()=='\n'||trimmed.front()=='\r')) trimmed.erase(trimmed.begin());
while (!trimmed.empty() && (trimmed.back()==' '||trimmed.back()=='\t'||trimmed.back()=='\n'||trimmed.back()=='\r')) trimmed.pop_back();
snprintf(import_key_input_, sizeof(import_key_input_), "%s", trimmed.c_str());
}
}
ImGui::SameLine();
if (ui::material::StyledButton(TR("clear"), ImVec2(0, 0), ui::material::resolveButtonFont(btnFont))) {
memset(import_key_input_, 0, sizeof(import_key_input_));
}
// Trimmed view for validation (importPrivateKey trims again before the RPC). The indicator and the
// Import-button guard below both derive from the SAME shared classifier, so they can't disagree.
if (m::TactileButton(TR("clear"))) sodium_memzero(import_key_input_, sizeof(import_key_input_));
// Trimmed view + classification. The indicator and the Import guard share isRecognizedImportKey.
std::string keyTrim(import_key_input_);
while (!keyTrim.empty() && (keyTrim.front()==' '||keyTrim.front()=='\t'||keyTrim.front()=='\n'||keyTrim.front()=='\r')) keyTrim.erase(keyTrim.begin());
while (!keyTrim.empty() && (keyTrim.back()==' '||keyTrim.back()=='\t'||keyTrim.back()=='\n'||keyTrim.back()=='\r')) keyTrim.pop_back();
bool keyRecognized = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
bool keyIsZ = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
bool recognized = services::WalletSecurityController::isRecognizedImportKey(keyTrim);
bool viewing = services::WalletSecurityController::isViewingKey(keyTrim);
bool shielded = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
// Key validation indicator
if (!keyTrim.empty()) {
if (keyRecognized) {
ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
if (recognized) {
const char* label = viewing ? TR("import_key_type_zview")
: shielded ? TR("import_key_type_zspend")
: TR("import_key_type_tkey");
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f);
ImGui::TextColored(ui::material::SuccessVec4(), "%s",
keyIsZ ? "Shielded spending key" : "Transparent private key");
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(m::SuccessVec4(), "%s", label);
} else {
ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "Unrecognized key format");
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("import_key_type_unknown"));
}
}
ImGui::Spacing();
if (!import_status_.empty()) {
if (import_success_) {
ImGui::TextColored(ui::material::SuccessVec4(), "%s", import_status_.c_str());
} else {
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
// Progress / result / error.
if (import_in_progress_) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
TR("import_key_rescanning"), m::LoadingDots());
} else if (import_success_) {
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(m::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(m::SuccessVec4(), "%s", TR("import_key_done"));
if (!import_result_address_.empty()) {
ImGui::Spacing();
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_address"));
ui::widgets::AddressCopyField("##importedaddr", import_result_address_);
}
} else if (!import_status_.empty()) {
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", import_status_.c_str());
ImGui::PopTextWrapPos();
}
ImGui::Spacing();
ImGui::Separator();
ImGui::BeginDisabled(!keyRecognized); // only a recognized Z/T key can be imported
if (ui::material::StyledButton("Import", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
importPrivateKey(std::string(import_key_input_), [this](bool success, const std::string& msg) {
import_success_ = success;
import_status_ = msg;
if (success) {
memset(import_key_input_, 0, sizeof(import_key_input_));
ImGui::Spacing();
float btnW = 130.0f * dp;
bool canImport = recognized && state_.connected && !import_in_progress_;
ImGui::BeginDisabled(!canImport);
if (m::TactileButton(TR("import_key_import"), ImVec2(btnW, 0))) {
import_in_progress_ = true;
import_success_ = false;
import_status_.clear();
import_result_address_.clear();
importPrivateKey(std::string(import_key_input_),
[this](bool ok, const std::string& err, const std::string& addr) {
import_in_progress_ = false;
import_success_ = ok;
if (ok) {
import_result_address_ = addr;
import_status_.clear();
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // wipe on success
} else {
import_status_ = err;
}
});
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
show_import_key_ = false;
if (m::TactileButton(TR("close"), ImVec2(btnW, 0))) show_import_key_ = false;
// Wipe the key the moment the dialog closes (Close button OR outside-click, which clears
// show_import_key_ mid-frame). in_progress is left to the callback to clear.
if (!show_import_key_) {
sodium_memzero(import_key_input_, sizeof(import_key_input_));
import_status_.clear();
memset(import_key_input_, 0, sizeof(import_key_input_));
import_result_address_.clear();
import_success_ = false;
import_key_reveal_ = false;
}
ui::material::EndOverlayDialog();
m::EndOverlayDialog();
}
void App::renderExportKeyDialog()

View File

@@ -300,7 +300,10 @@ public:
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
// callback receives (keys, exportedCount, totalAddresses) so callers can detect a keyless/partial export.
void exportAllKeys(std::function<void(const std::string&, int, int)> callback);
void importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback);
// callback(success, errorOrEmpty, importedAddress). address is "" on failure or when the RPC
// returns none; the import routes to z_importviewingkey / z_importkey / importprivkey by key type.
void importPrivateKey(const std::string& key,
std::function<void(bool, const std::string&, const std::string&)> callback);
// Wallet backup
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
@@ -802,6 +805,9 @@ private:
std::string export_address_;
std::string import_status_;
bool import_success_ = false;
bool import_key_reveal_ = false; // show the key in plaintext (default masked)
bool import_in_progress_ = false; // an import + rescan is running (disable/spinner)
std::string import_result_address_; // address imported on success (shown as a copy field)
std::string backup_status_;
bool backup_success_ = false;

View File

@@ -2921,10 +2921,11 @@ void App::exportAllKeys(std::function<void(const std::string&, int, int)> callba
}
}
void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, const std::string&)> callback)
void App::importPrivateKey(const std::string& rawKey,
std::function<void(bool, const std::string&, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {
if (callback) callback(false, "Not connected");
if (callback) callback(false, "Not connected", "");
return;
}
@@ -2934,23 +2935,33 @@ void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, c
while (!key.empty() && (key.front()==' '||key.front()=='\t'||key.front()=='\n'||key.front()=='\r')) key.erase(key.begin());
while (!key.empty() && (key.back()==' '||key.back()=='\t'||key.back()=='\n'||key.back()=='\r')) key.pop_back();
// Reject anything that doesn't look like a Z/T private key before handing it to the daemon (the
// dialog's indicator and this guard now share isRecognizedPrivateKey, so they can't disagree).
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
if (callback) callback(false, "Unrecognized private-key format.");
// Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing
// it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey).
if (!services::WalletSecurityController::isRecognizedImportKey(key)) {
if (callback) callback(false, "Unrecognized key format.", "");
return;
}
const bool viewing = services::WalletSecurityController::isViewingKey(key);
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the
// synchronous curl call can take many seconds; never block the UI thread on it.
worker_->post([this, key, shielded, callback]() -> rpc::RPCWorker::MainCb {
std::string err;
worker_->post([this, key, viewing, shielded, callback]() -> rpc::RPCWorker::MainCb {
std::string err, addr;
try {
rpc::RPCClient::TraceScope trace("Settings / Import private key");
if (shielded) rpc_->call("z_importkey", {key, "yes"}); // rescan
else rpc_->call("importprivkey", {key, "", true}); // label "", rescan
rpc::RPCClient::TraceScope trace("Settings / Import key");
std::string method;
nlohmann::json params;
if (viewing) { method = "z_importviewingkey"; params = {key, "yes"}; } // watch-only
else if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
else { method = "importprivkey"; params = {key, "", true}; }
nlohmann::json r = rpc_->call(method, params);
// z_import* return {type,address}; importprivkey returns the t-address string.
if (r.is_object() && r.contains("address") && r["address"].is_string())
addr = r["address"].get<std::string>();
else if (r.is_string())
addr = r.get<std::string>();
} catch (const std::exception& e) {
err = e.what();
} catch (...) {
@@ -2958,16 +2969,14 @@ void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, c
// below would never run, leaving a stuck "Importing…" spinner.
err = "Import failed (unknown error)";
}
return [this, shielded, err, callback]() {
return [this, err, addr, callback]() {
if (!err.empty()) {
if (callback) callback(false, err);
if (callback) callback(false, err, "");
return;
}
invalidateAddressValidationCache();
refreshAddresses();
if (callback) callback(true, services::WalletSecurityController::importSuccessMessage(
shielded ? services::WalletSecurityController::KeyKind::Shielded
: services::WalletSecurityController::KeyKind::Transparent));
if (callback) callback(true, "", addr);
};
});
}

View File

@@ -101,10 +101,18 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
// (The old `key[0]=='s'` test misrouted an uppercase "SK..." shielded key to the transparent RPC.)
if (key.rfind("secret-extended-key-", 0) == 0) return KeyKind::Shielded;
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return KeyKind::Shielded;
if (!key.empty() && key[0] == 's') return KeyKind::Shielded;
// A "z"-prefixed key is a shielded viewing key (zxview…); "s"-prefixed is a legacy Sprout key.
if (!key.empty() && (key[0] == 's' || key[0] == 'z')) return KeyKind::Shielded;
return KeyKind::Transparent;
}
bool WalletSecurityController::isViewingKey(const std::string& key)
{
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them.
return key.rfind("zxview", 0) == 0;
}
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
{
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
@@ -115,6 +123,11 @@ bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
return false;
}
bool WalletSecurityController::isRecognizedImportKey(const std::string& key)
{
return isRecognizedPrivateKey(key) || isViewingKey(key);
}
const char* WalletSecurityController::importSuccessMessage(KeyKind kind)
{
return kind == KeyKind::Shielded

View File

@@ -74,9 +74,13 @@ public:
std::size_t minLength = 4);
static KeyKind classifyAddress(const std::string& address);
static KeyKind classifyPrivateKey(const std::string& key);
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only).
static bool isViewingKey(const std::string& key);
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
// Single source of truth for the import dialog's indicator AND its submit guard.
static bool isRecognizedPrivateKey(const std::string& key);
// As above, but also accepts a recognized shielded viewing key — the import dialog auto-detects
// both, so this is the single source of truth for its indicator AND its submit guard.
static bool isRecognizedImportKey(const std::string& key);
static const char* importSuccessMessage(KeyKind kind);
static std::string decryptExportFileName(std::uint64_t timestampSeconds);
static void secureClear(std::string& value);

View File

@@ -1346,6 +1346,18 @@ void I18n::loadBuiltinEnglish()
strings_["import_key_tooltip"] = "Enter one or more private keys, one per line.\nSupports both z-address and t-address keys.\nLines starting with # are treated as comments.";
strings_["import_key_warning"] = "Warning: Never share your private keys! Importing keys from untrusted sources can compromise your wallet.";
strings_["import_key_z_format"] = "Z-address spending keys (secret-extended-key-...)";
strings_["import_key_warn"] = "Only import a key you own \xE2\x80\x94 it grants access to its funds.";
strings_["import_key_need_node"] = "Connect a running node to import a key.";
strings_["import_key_field"] = "Key";
strings_["import_key_reveal_tip"] = "Show/hide the key";
strings_["import_key_type_tkey"] = "Transparent private key";
strings_["import_key_type_zspend"] = "Shielded spending key";
strings_["import_key_type_zview"] = "Shielded viewing key (watch-only)";
strings_["import_key_type_unknown"] = "Unrecognized key format";
strings_["import_key_rescanning"] = "Importing & rescanning \xE2\x80\x94 this can take several minutes";
strings_["import_key_done"] = "Imported. Wallet is rescanning.";
strings_["import_key_address"] = "Address:";
strings_["import_key_import"] = "Import";
// --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet...";