4 Commits
Author SHA1 Message Date
DanSandClaude Opus 4.8 8961cbdfc9 feat(export): sanitize CSV filename + Open-folder button
The transactions CSV export now sanitizes the typed filename (basename only,
illegal chars → '_', no leading dots/trailing dots-or-spaces, length-capped,
always .csv) so it can't be pointed outside the config dir, and the success
screen gets an "Open folder" button. (All-vs-filter / date-range export scope is
left for a follow-up — it needs tx-tab filter-snapshot plumbing.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:07 -05:00
DanSandClaude Opus 4.8 794150b11b feat(chat,notifications): first-run chat intro card; clickable toast history + "+N" badge
- Chat: a dismissible first-run intro card (how chat works — encrypted, sent as
  shielded txs with a small fee, tied to the seed) with a "Copy my chat address"
  button, shown in the empty thread pane; dismissal persisted. The copy logic is
  shared with the existing sidebar button (moved to a helper, unchanged).
- Toasts: clicking a toast now opens the existing notification-history panel, and
  a "+N more" badge appears when several live toasts are queued (render only ever
  showed the newest). Badge opacity is a ui.toml token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:07 -05:00
DanSandClaude Opus 4.8 2e74a3ca69 feat(explorer,keys): distinguish lookup failures; key-export unlock affordance
- Explorer: a hash lookup now tells a transport/RPC failure apart from a genuine
  not-found (RPC -5/-8), showing "Lookup failed: <friendly error>" instead of
  "no block or transaction found"; block-detail errors routed through
  friendlyRpcError. getblock/getblockhash/getrawtransaction calls unchanged.
- Key-export: when the wallet is locked, the dialog now says "Unlock your wallet
  first" with an Unlock button (lite → requestLiteUnlock; full-node → close to
  reveal the lock screen) instead of a raw error, and the fetching state animates
  with LoadingDots. The export RPC/args are unchanged.

(i18n.cpp carries the additive keys for these and the chat/CSV commits below.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:06 -05:00
DanSandClaude Opus 4.8 e94d88e723 fix(console): confirmed destructive commands now run; gate z_exportmnemonic
Two gaps in the destructive-command confirm:
- Regression: a command confirmed via the command-reference dialog's "Run" was
  replayed through submitConsoleCommand, which re-armed the destructive gate and
  silently did nothing until the user retyped it. The dialog now pre-arms the
  exact-line confirm so the replay executes.
- z_exportmnemonic (full-node seed-phrase reveal) wasn't in the reference
  catalog, so a pasted z_exportmnemonic ran instantly. Added a small
  secret-revealing fallback set (z_exportmnemonic, z_exportviewingkey) to the
  destructive check. Command execution is otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-18 23:51:06 -05:00
12 changed files with 163 additions and 21 deletions
+1
View File
@@ -1226,6 +1226,7 @@ pill-rounding = { size = 12.0 }
padding-x = { size = 12.0 }
glass-fill-alpha = { size = 18.0 }
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
badge-fill-alpha = { size = 0.35 } # "+N more" badge fill opacity (fraction of the accent alpha)
glass-border-alpha = { size = 35.0 }
progress-bar-height = { size = 2.0 }
duration-info = { size = 1.0 }
+1 -1
View File
@@ -3317,7 +3317,7 @@ void App::renderStatusBar()
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
}
if (clicked) {
if (clicked || notes.consumeHistoryRequest()) { // bell click, or a click on the toast itself
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
ImGui::OpenPopup("##AlertHistoryPopup");
}
+2
View File
@@ -271,6 +271,7 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "chat_intro_dismissed", chat_intro_dismissed_);
loadScalar(j, "lock_failed_attempts", lock_failed_attempts_);
loadScalar(j, "lockout_until_epoch", lockout_until_epoch_);
loadScalar(j, "lock_state_wallet", lock_state_wallet_);
@@ -599,6 +600,7 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["chat_intro_dismissed"] = chat_intro_dismissed_;
j["lock_failed_attempts"] = lock_failed_attempts_;
j["lockout_until_epoch"] = lockout_until_epoch_;
j["lock_state_wallet"] = lock_state_wallet_;
+3
View File
@@ -392,6 +392,8 @@ public:
// Whether the one-time "back up your seed phrase" reminder has already been shown.
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
bool getChatIntroDismissed() const { return chat_intro_dismissed_; }
void setChatIntroDismissed(bool v) { chat_intro_dismissed_ = v; durable_next_save_ = true; }
// Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by
// killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
@@ -745,6 +747,7 @@ private:
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
bool chat_intro_dismissed_ = false; // one-time chat "how it works" intro card
int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve)
long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none)
std::string lock_state_wallet_; // wallet file the above two apply to
+26 -2
View File
@@ -100,6 +100,15 @@ void Notifications::render()
float iconGap = 4.0f * dp;
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
// "+N more" badge when several live toasts are queued (render shows only the newest).
const size_t moreCount = hiddenLiveCount();
std::string badge = moreCount ? ("+" + std::to_string(moreCount)) : std::string();
float badgeW = 0.0f, badgeGap = 0.0f;
if (!badge.empty() && textFont) {
badgeW = textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, badge.c_str()).x + 10.0f * dp;
badgeGap = 6.0f * dp;
pillWidth += badgeW + badgeGap;
}
// Clamp to reasonable bounds
float maxPillW = viewport->WorkSize.x * 0.5f;
if (pillWidth > maxPillW) pillWidth = maxPillW;
@@ -163,7 +172,7 @@ void Notifications::render()
bool clipped = false;
if (textFont) {
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
const float availTextW = (pMax.x - pillPadX) - cursorX;
const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap));
std::string shown = notif.message;
if (availTextW > 0.0f &&
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
@@ -183,8 +192,23 @@ void Notifications::render()
dl->PopClipRect();
}
// Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped.
// "+N more" badge, right-aligned inside the pill.
if (!badge.empty() && textFont) {
float badgeX = pMax.x - pillPadX - badgeW;
ImVec4 bf = accent_color; bf.w *= nde("badge-fill-alpha", 0.35f);
dl->AddRectFilled(ImVec2(badgeX, pillY + 3.0f * dp), ImVec2(badgeX + badgeW, pMax.y - 3.0f * dp),
ImGui::ColorConvertFloat4ToU32(bf), (pillHeight - 6.0f * dp) * 0.5f);
dl->AddText(textFont, textFont->LegacySize, ImVec2(badgeX + 5.0f * dp, contentY),
ImGui::ColorConvertFloat4ToU32(text_color), badge.c_str());
}
// Hover: pause the auto-dismiss (next frame), reveal the full message if clipped, and allow a click to
// open the notification history (the status bar consumes the request).
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
if (toast_hovered_) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
}
if (toast_hovered_ && clipped)
ImGui::SetTooltip("%s", notif.message.c_str());
}
+13
View File
@@ -169,8 +169,20 @@ public:
void clear() {
notifications_.clear();
history_requested_ = false;
}
/// Number of currently-live toasts beyond the one the pill shows (for a "+N more" badge).
size_t hiddenLiveCount() const {
const auto now = std::chrono::steady_clock::now();
size_t live = 0;
for (const auto& n : notifications_) if (!n.isExpiredAt(now)) ++live;
return live > 1 ? live - 1 : 0;
}
/// render() sets this when the toast is clicked; the status bar consumes it to open the history panel.
void requestHistory() { history_requested_ = true; }
bool consumeHistoryRequest() { bool r = history_requested_; history_requested_ = false; return r; }
void setMaxNotifications(size_t max) {
max_notifications_ = max;
}
@@ -191,6 +203,7 @@ private:
Notifications& operator=(const Notifications&) = delete;
std::deque<Notification> notifications_;
bool history_requested_ = false; // set on toast click, consumed by the status bar to open history
size_t max_notifications_ = 5;
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
std::chrono::steady_clock::time_point last_drawn_{};
+40 -11
View File
@@ -591,6 +591,17 @@ void centeredHint(const char* text) {
}
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
// Copy the user's own chat address to the clipboard (shared by the sidebar button and the intro card).
void copyMyChatAddress(App* app) {
std::string myAddr = app->chatMyAddress();
if (myAddr.empty()) {
Notifications::instance().warning(TR("chat_no_my_address"));
} else {
ImGui::SetClipboardText(myAddr.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos();
@@ -956,15 +967,7 @@ void RenderChatTab(App* app)
{
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
if (ImGui::SmallButton(TR("chat_copy_my_address"))) {
std::string myAddr = app->chatMyAddress();
if (myAddr.empty()) {
Notifications::instance().warning(TR("chat_no_my_address"));
} else {
ImGui::SetClipboardText(myAddr.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
}
@@ -1875,9 +1878,35 @@ void RenderChatTab(App* app)
ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
} else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) {
// First-run intro: explain how chat works + let the user copy their address, instead of a bare
// "no conversations yet" line. Dismissal is persisted.
const float idp = Layout::dpiScale();
const float availX = ImGui::GetContentRegionAvail().x;
const float cardW = std::min(availX - 40.0f * idp, 460.0f * idp);
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - cardW) * 0.5f));
ImGui::BeginGroup();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + cardW);
material::Type().textColored(material::TypeStyle::Subtitle1, material::OnSurface(), TR("chat_intro_title"));
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
ImGui::TextWrapped("%s", TR("chat_intro_body"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("chat_intro_fee"));
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
if (material::TactileButton(TR("chat_copy_my_address"), ImVec2(cardW, 0))) copyMyChatAddress(app);
ImGui::Spacing();
if (material::TactileButton(TR("chat_intro_dismiss"), ImVec2(cardW, 0))) {
app->settings()->setChatIntroDismissed(true);
app->settings()->save();
}
ImGui::EndGroup();
} else if (convs.empty()) {
centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
} else {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
}
}
ImGui::EndChild();
+9
View File
@@ -1552,6 +1552,12 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
if (n == firstLower) return cat.commands[i].destructive;
}
}
// Secret-revealing full-node commands that aren't in the reference catalog still deserve the gate
// (e.g. z_exportmnemonic dumps the seed phrase; the lite "seed" equivalent is already flagged).
if (fullNode) {
static const char* kSecretReveal[] = { "z_exportmnemonic", "z_exportviewingkey" };
for (const char* s : kSecretReveal) if (firstLower == s) return true;
}
return false;
}
} // namespace
@@ -1882,6 +1888,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
ImGui::SameLine();
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
pending_submit_ = built;
// The reference dialog already confirmed this run, so pre-arm the destructive gate to an exact
// match — otherwise the replayed submit re-arms it and silently does nothing until retyped.
destructive_confirm_cmd_ = built;
command_search_[0] = '\0';
run_confirm_cmd_ = nullptr;
show_commands_popup_ = false;
+14 -4
View File
@@ -269,7 +269,7 @@ static void fetchBlockDetail(App* app, int height) {
auto hashResult = rpc->call("getblockhash", {height});
std::string hash = hashResult.get<std::string>();
result = rpc->call("getblock", {hash});
} catch (const std::exception& e) { error = e.what(); }
} catch (const std::exception& e) { error = util::friendlyRpcError(e.what()); }
return [result, error]() {
applyBlockDetailResult(result, error);
};
@@ -383,19 +383,27 @@ static void navigateToHash(App* app, const std::string& hash) {
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
json blockResult, txResult;
bool gotBlock = false, gotTx = false;
bool rpcFailed = false; std::string rpcErr;
// Distinguish a genuine "not found" (RPC -5/-8) from a transport/timeout failure, so a flaky
// connection isn't reported as "no block or transaction found".
auto noteErr = [&](const std::exception& e) {
const auto* re = dynamic_cast<const rpc::RpcError*>(&e);
if (re && (re->code == -5 || re->code == -8)) return; // not-found: a real negative answer
if (!rpcFailed) { rpcFailed = true; rpcErr = e.what(); }
};
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
blockResult = rpc->call("getblock", {hash});
gotBlock = true;
} catch (...) {}
} catch (const std::exception& e) { noteErr(e); }
if (!gotBlock) {
try {
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
txResult = rpc->call("getrawtransaction", {hash, 1});
gotTx = true;
} catch (...) {}
} catch (const std::exception& e) { noteErr(e); }
}
return [blockResult, txResult, gotBlock, gotTx]() {
return [blockResult, txResult, gotBlock, gotTx, rpcFailed, rpcErr]() {
s_search_loading = false;
if (gotBlock) {
int h = blockResult.value("height", -1);
@@ -407,6 +415,8 @@ static void navigateToHash(App* app, const std::string& hash) {
s_tx_loading = false;
s_expanded_tx_idx = 0;
s_show_detail_modal = false;
} else if (rpcFailed) {
s_search_error = std::string(TR("explorer_lookup_failed")) + ": " + util::friendlyRpcError(rpcErr);
} else {
s_search_error = TR("explorer_hash_not_found");
}
+34 -1
View File
@@ -18,6 +18,8 @@
#include <string>
#include <ctime>
#include <algorithm>
#include <cctype>
namespace dragonx {
namespace ui {
@@ -28,6 +30,31 @@ static char s_filename[256] = "";
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
static std::string s_error; // error message on failure; empty otherwise
// Make a user-typed filename safe to write into the config dir: basename only, illegal chars → '_',
// no leading dots / trailing dots-or-spaces, length-capped, always ending in .csv.
static std::string sanitizeExportFilename(std::string name) {
const size_t slash = name.find_last_of("/\\");
if (slash != std::string::npos) name = name.substr(slash + 1);
auto notspace = [](unsigned char c) { return !std::isspace(c); };
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notspace));
name.erase(std::find_if(name.rbegin(), name.rend(), notspace).base(), name.end());
for (char& c : name) {
const unsigned char u = static_cast<unsigned char>(c);
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*')
c = '_';
}
const size_t firstGood = name.find_first_not_of('.');
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
if (name.size() > 120) name.resize(120);
std::string lower = name;
std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lower.size() < 4 || lower.compare(lower.size() - 4, 4, ".csv") != 0) name += ".csv";
if (name.empty() || name == ".csv") name = "dragonx_transactions.csv";
return name;
}
void ExportTransactionsDialog::show()
{
s_open = true;
@@ -94,6 +121,8 @@ void ExportTransactionsDialog::render(App* app)
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
widgets::AddressCopyField("##exportedpath", s_saved_path);
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
util::Platform::openFolder(util::Platform::getConfigDir());
} else if (!s_error.empty()) {
ImGui::Spacing();
ImGui::PushTextWrapPos(0.0f);
@@ -113,7 +142,11 @@ void ExportTransactionsDialog::render(App* app)
Notifications::instance().warning(TR("export_tx_none"));
} else {
std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + s_filename;
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
// the config dir by a pasted path).
std::string safeName = sanitizeExportFilename(s_filename);
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
std::string filepath = configDir + "/" + safeName;
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
const std::string csv = data::formatTransactionsCsv(state.transactions);
+12 -2
View File
@@ -121,7 +121,7 @@ void KeyExportDialog::render(App* app)
ImGui::Text("%s", key_label);
if (s_fetching) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching"));
ImGui::TextColored(material::WarningVec4(), "%s%s", TR("key_export_fetching_base"), material::LoadingDots());
} else if (!s_error.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str());
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
@@ -133,6 +133,16 @@ void KeyExportDialog::render(App* app)
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("invalid_address"));
} else if (s_key.empty() && app->getWalletState().isLocked()) {
// Reveal would just fail with a locked-wallet RPC error — say so and offer to unlock.
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
ImGui::TextWrapped("%s", TR("key_export_unlock_first"));
ImGui::PopStyleColor();
ImGui::Spacing();
if (material::TactileButton(TR("lite_unlock"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
if (app->liteWallet()) app->requestLiteUnlock();
else s_open = false; // full node: closing reveals the lock screen already shown while locked
}
} else if (s_key.empty()) {
// Show button to fetch key
if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
@@ -173,7 +183,7 @@ void KeyExportDialog::render(App* app)
s_key = found;
s_show_key = wantViewing; // viewing keys are less sensitive
} else {
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : r.error;
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : util::friendlyRpcError(r.error);
}
wallet::secureWipeLiteSecret(found);
s_fetching = false;
+8
View File
@@ -268,6 +268,10 @@ void I18n::loadBuiltinEnglish()
strings_["chat_retry"] = "Retry";
strings_["chat_jump_latest"] = "Latest";
strings_["chat_empty_title"] = "No conversations yet";
strings_["chat_intro_title"] = "How chat works";
strings_["chat_intro_body"] = "Messages are end-to-end encrypted and sent as shielded transactions, so only you and your contact can read them. Your chat identity is tied to your wallet seed, so restoring your seed restores your chat address.";
strings_["chat_intro_fee"] = "Each message costs a small network fee. Share your chat address to let people start a conversation with you.";
strings_["chat_intro_dismiss"] = "Got it";
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
strings_["chat_copy_my_address"] = "Copy my chat address";
strings_["chat_my_address_tip"] = "Share this address so others can message you";
@@ -2126,6 +2130,7 @@ void I18n::loadBuiltinEnglish()
strings_["export_tx_file_fail"] = "Failed to create CSV file";
strings_["export_tx_none"] = "No transactions to export";
strings_["export_tx_success"] = "Transactions exported successfully";
strings_["export_tx_open_folder"] = "Open folder";
strings_["export_tx_title"] = "Export Transactions to CSV";
// --- Import Key Dialog ---
@@ -2177,6 +2182,8 @@ void I18n::loadBuiltinEnglish()
// --- Key Export Dialog ---
strings_["key_export_fetching"] = "Fetching key from wallet...";
strings_["key_export_fetching_base"] = "Fetching key from wallet";
strings_["key_export_unlock_first"] = "Unlock your wallet first, then reveal the key.";
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
strings_["key_export_private_key"] = "Private Key:";
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
@@ -2846,6 +2853,7 @@ void I18n::loadBuiltinEnglish()
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
strings_["explorer_invalid_response"] = "Invalid response from daemon";
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
strings_["explorer_lookup_failed"] = "Lookup failed";
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
strings_["explorer_no_results"] = "No matching cached blocks";
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";