feat(settings): split import into separate key + viewing-key buttons

Backup & Data now has two distinct import actions instead of one
auto-detecting dialog:

- "Import Key" imports a spending key (transparent WIF or shielded
  z-spending key) with the strong "grants access to funds" warning.
- "Import Viewing Key" imports a watch-only shielded viewing key
  (zxviews…) with a milder eye/watch-only note and an optional
  "scan from block height" field — z_importviewingkey accepts a start
  height, so watching a recent address needn't rescan the whole chain.

The shared Material dialog (renderImportKeyDialog) branches on
import_view_mode_: title, warning tone, field label, the live type
indicator, and the recognition guard. A key valid for the *other*
button (e.g. a spending key pasted into the viewing-key dialog) now
shows a redirect hint rather than a generic "unrecognized" error.

importPrivateKey gains a startHeight arg, appended to the shielded
import RPCs (z_importviewingkey / z_importkey) and ignored for
transparent WIF (importprivkey has no start-height param). The
scan-height buffer is wiped on open/close alongside the key buffer.

i18n: 9 new keys (button label + tooltip, viewing title/note/field,
scan label/hint, two wrong-type redirect hints) with translations for
all 8 languages; CJK subset rebuilt (+1 glyph). A modal-import-viewkey
sweep surface is added for the viewing-mode dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 13:53:35 -05:00
parent 46f3001360
commit f428292ea4
15 changed files with 155 additions and 18 deletions

View File

@@ -2758,9 +2758,10 @@ void App::renderImportKeyDialog()
{
namespace m = ui::material;
float dp = ui::Layout::dpiScale();
const bool viewMode = import_view_mode_; // watch-only shielded viewing key vs. spending key
m::OverlayDialogSpec ov;
ov.title = TR("import_key_title");
ov.title = viewMode ? TR("import_viewkey_title") : TR("import_key_title");
ov.p_open = &show_import_key_;
ov.style = m::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f;
@@ -2773,12 +2774,24 @@ void App::renderImportKeyDialog()
import_status_.clear();
import_result_address_.clear();
import_key_reveal_ = false;
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
}
// 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"));
// Handle-with-care note. A spending key grants access to funds (strong warning); a viewing key
// is watch-only (milder, informational eye note).
if (viewMode) {
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), ICON_MD_VISIBILITY);
ImGui::PopFont();
ImGui::SameLine(0, 6.0f * dp);
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_viewkey_note"));
ImGui::PopTextWrapPos();
} else {
m::DialogWarningHeader(TR("import_key_warn"));
}
ImGui::Spacing();
// A running node is required — say so up front instead of failing only after Import.
@@ -2788,7 +2801,7 @@ void App::renderImportKeyDialog()
}
// Masked key field with a reveal (eye) toggle + Paste/Clear.
ImGui::TextUnformatted(TR("import_key_field"));
ImGui::TextUnformatted(viewMode ? TR("import_viewkey_field") : TR("import_key_field"));
float eyeW = ImGui::GetFrameHeight();
ImGui::SetNextItemWidth(-(eyeW + ui::Layout::spacingSm()));
ImGuiInputTextFlags flags = import_key_reveal_ ? 0 : ImGuiInputTextFlags_Password;
@@ -2815,31 +2828,52 @@ void App::renderImportKeyDialog()
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 recognized = services::WalletSecurityController::isRecognizedImportKey(keyTrim);
bool viewing = services::WalletSecurityController::isViewingKey(keyTrim);
bool isView = services::WalletSecurityController::isViewingKey(keyTrim);
bool isSpend = services::WalletSecurityController::isRecognizedPrivateKey(keyTrim);
bool shielded = services::WalletSecurityController::classifyPrivateKey(keyTrim)
== services::WalletSecurityController::KeyKind::Shielded;
// Each button accepts only its own key type; a key valid for the OTHER button is flagged with a
// redirect hint rather than lumped in with truly unrecognized input.
bool recognized = viewMode ? isView : isSpend;
bool wrongType = viewMode ? isSpend : isView;
if (!keyTrim.empty()) {
if (recognized) {
const char* label = viewing ? TR("import_key_type_zview")
: shielded ? TR("import_key_type_zspend")
: TR("import_key_type_tkey");
const char* label = viewMode ? 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 * dp);
ImGui::TextColored(m::SuccessVec4(), "%s", label);
} else {
const char* msg = wrongType
? (viewMode ? TR("import_viewkey_wrong_type") : TR("import_key_wrong_type"))
: TR("import_key_type_unknown");
ImGui::PushFont(m::Type().iconSmall());
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), wrongType ? ICON_MD_WARNING : ICON_MD_HELP);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f * dp);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("import_key_type_unknown"));
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", msg);
ImGui::PopTextWrapPos();
}
}
ImGui::Spacing();
// Optional scan-from height (viewing keys only — z_importviewingkey takes a start height, so a
// watch-only import of a recent address needn't rescan the whole chain).
if (viewMode) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_viewkey_scan_label"));
ImGui::SetNextItemWidth(140.0f * dp);
ImGui::InputText("##importscanheight", import_key_scan_height_, sizeof(import_key_scan_height_),
ImGuiInputTextFlags_CharsDecimal);
ImGui::SameLine(0, ui::Layout::spacingSm());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("import_viewkey_scan_hint"));
ImGui::Spacing();
}
// Progress / result / error.
if (import_in_progress_) {
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::Primary()), "%s%s",
@@ -2873,7 +2907,12 @@ void App::renderImportKeyDialog()
import_success_ = false;
import_status_.clear();
import_result_address_.clear();
importPrivateKey(std::string(import_key_input_),
int startHeight = 0; // viewing-key only; 0 = full rescan (also the transparent/spending default)
if (viewMode && import_key_scan_height_[0]) {
startHeight = std::atoi(import_key_scan_height_);
if (startHeight < 0) startHeight = 0;
}
importPrivateKey(std::string(import_key_input_), startHeight,
[this](bool ok, const std::string& err, const std::string& addr) {
import_in_progress_ = false;
import_success_ = ok;
@@ -2894,6 +2933,7 @@ void App::renderImportKeyDialog()
// 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_));
sodium_memzero(import_key_scan_height_, sizeof(import_key_scan_height_));
import_status_.clear();
import_result_address_.clear();
import_success_ = false;

View File

@@ -302,7 +302,8 @@ public:
void exportAllKeys(std::function<void(const std::string&, int, int)> 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,
// startHeight > 0 rescans from that block (shielded RPCs only; ignored for transparent WIF).
void importPrivateKey(const std::string& key, int startHeight,
std::function<void(bool, const std::string&, const std::string&)> callback);
// Wallet backup
@@ -373,7 +374,8 @@ public:
std::string screenshotDir() const; // <config>/screenshots (fixed; sweeps overwrite in place)
// Dialog triggers (used by settings page to open modal dialogs)
void showImportKeyDialog() { show_import_key_ = true; }
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
void showExportKeyDialog() { show_export_key_ = true; }
void showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; }
@@ -808,6 +810,8 @@ private:
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)
bool import_view_mode_ = false; // dialog mode: false = spending key, true = viewing key
char import_key_scan_height_[16] = {0}; // optional rescan start height (viewing-key mode)
std::string backup_status_;
bool backup_success_ = false;

View File

@@ -2921,7 +2921,7 @@ void App::exportAllKeys(std::function<void(const std::string&, int, int)> callba
}
}
void App::importPrivateKey(const std::string& rawKey,
void App::importPrivateKey(const std::string& rawKey, int startHeight,
std::function<void(bool, const std::string&, const std::string&)> callback)
{
if (!state_.connected || !rpc_ || !worker_) {
@@ -2947,7 +2947,7 @@ void App::importPrivateKey(const std::string& rawKey,
== 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, viewing, shielded, callback]() -> rpc::RPCWorker::MainCb {
worker_->post([this, key, viewing, shielded, startHeight, callback]() -> rpc::RPCWorker::MainCb {
std::string err, addr;
try {
rpc::RPCClient::TraceScope trace("Settings / Import key");
@@ -2956,6 +2956,8 @@ void App::importPrivateKey(const std::string& rawKey,
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}; }
// A start height (shielded RPCs only) rescans from that block instead of genesis.
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
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())

View File

@@ -261,7 +261,9 @@ void App::buildSweepCatalog()
// Simple bool-flag modals.
add("modal-import-key", ui::NavPage::Overview,
[](App& a) { a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; });
[](App& a) { a.import_view_mode_ = false; a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; });
add("modal-import-viewkey", ui::NavPage::Overview,
[](App& a) { a.import_view_mode_ = true; a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; a.import_view_mode_ = false; });
add("modal-export-key", ui::NavPage::Overview,
[](App& a) { a.show_export_key_ = true; }, [](App& a) { a.show_export_key_ = false; });
add("modal-backup", ui::NavPage::Overview,

View File

@@ -1349,12 +1349,14 @@ void RenderSettingsPage(App* app) {
float naturalW = 0;
for (int i = 0; i < 5; i++)
naturalW += ImGui::CalcTextSize(r1[i]).x + btnPadX;
float impViewW = ImGui::CalcTextSize(TR("settings_import_viewkey")).x + btnPadX;
naturalW += impViewW; // the extra "Import viewing key" button (rendered after Import key)
float wizW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(wizLabel).x + btnPadX : 0.0f;
float bsW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(bsLabel).x + btnPadX : 0.0f;
// Full-node-only "Seed phrase" + "Migrate to seed" buttons trail the Backup button.
float seedW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_backup_button")).x + btnPadX : 0.0f;
float migrateW = showFullNodeLifecycleActions ? ImGui::CalcTextSize(TR("seed_migrate_button")).x + btnPadX : 0.0f;
float totalW = naturalW + sp * 5;
float totalW = naturalW + sp * 6; // 6 base buttons (incl. Import viewing key)
if (showFullNodeLifecycleActions) totalW += wizW + bsW + seedW + migrateW + sp * 4;
float scale = (totalW > contentW) ? contentW / totalW : 1.0f;
@@ -1364,6 +1366,11 @@ void RenderSettingsPage(App* app) {
if (TactileButton(r1[0], ImVec2(0, 0), btnFont))
app->showImportKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", t1[0]);
// Watch-only shielded viewing-key import (separate button — different key type + scan-height).
ImGui::SameLine(0, scaledSp);
if (TactileButton(TR("settings_import_viewkey"), ImVec2(0, 0), btnFont))
app->showImportViewingKeyDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
ImGui::SameLine(0, scaledSp);
if (TactileButton(r1[1], ImVec2(0, 0), btnFont))
app->showExportKeyDialog();

View File

@@ -429,6 +429,7 @@ void I18n::loadBuiltinEnglish()
strings_["settings_merge_to_address"] = "Merge to Address...";
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
strings_["settings_import_key"] = "Import Key...";
strings_["settings_import_viewkey"] = "Import Viewing Key...";
strings_["settings_export_key"] = "Export Key...";
strings_["settings_export_all"] = "Export All...";
strings_["settings_backup"] = "Backup...";
@@ -731,6 +732,7 @@ void I18n::loadBuiltinEnglish()
// Settings: additional tooltips (keys/data row)
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
strings_["tt_export_key"] = "Export the private key for the selected address";
strings_["tt_export_all"] = "Export all private keys to a file";
strings_["tt_backup"] = "Create a backup of your wallet.dat file";
@@ -1358,6 +1360,14 @@ void I18n::loadBuiltinEnglish()
strings_["import_key_done"] = "Imported. Wallet is rescanning.";
strings_["import_key_address"] = "Address:";
strings_["import_key_import"] = "Import";
// Viewing-key (watch-only) import — separate button + dialog mode.
strings_["import_viewkey_title"] = "Import Viewing Key";
strings_["import_viewkey_note"] = "Watch-only: a viewing key reveals an address's balance and transactions but cannot spend its funds.";
strings_["import_viewkey_field"] = "Viewing key";
strings_["import_key_wrong_type"] = "This looks like a viewing key. Use \"Import Viewing Key\" instead.";
strings_["import_viewkey_wrong_type"] = "This looks like a spending key. Use \"Import Key\" instead.";
strings_["import_viewkey_scan_label"] = "Scan from block height (optional)";
strings_["import_viewkey_scan_hint"] = "0 = rescan from the start";
// --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet...";