fix: Tier-2 UX robustness — action guards, in-progress guards, input trimming

The app-wide "import-key pattern" fixes (missing guards / untrimmed input / no
re-entrancy protection), from the robustness audit:

- Import-key dialog: unify the type indicator and the RPC dispatch on one
  classifier (classifyPrivateKey is now prefix-aware — an "SK..." shielded key no
  longer misroutes to the transparent RPC), trim manual input (not just Paste),
  and gate both the indicator and the Import button on a shared
  isRecognizedPrivateKey() so unrecognized/empty input can't be submitted.
- Shield: guard the submit button on connected + !syncing (like Send), with a
  disabled tooltip, instead of firing into a raw daemon error.
- Mining: disable the pool Mine button when the payout address is empty
  ("enter a payout address first"), and trim the pool URL/worker on persist.
- Console: track in-flight RPC commands (atomic counter + busy() override) so the
  input is disabled while a command runs instead of piling up on the worker.
- Maintenance (rescan/repair/delete-chain/reinstall-daemon): re-entrancy guard —
  bail with "already in progress" instead of launching a duplicate destructive op.
- Bootstrap dialog: re-attach to an in-flight download on reopen instead of
  resetting state and orphaning the running worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 13:09:23 -05:00
parent df14533ad3
commit 129a8e6449
10 changed files with 122 additions and 28 deletions

View File

@@ -2650,20 +2650,24 @@ void App::renderImportKeyDialog()
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.
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;
// Key validation indicator
if (import_key_input_[0] != '\0') {
std::string k(import_key_input_);
bool isZKey = (k.substr(0, 20) == "secret-extended-key-") ||
(k.length() >= 2 && k[0] == 'S' && k[1] == 'K');
bool isTKey = (k.length() >= 51 && k.length() <= 52 &&
(k[0] == '5' || k[0] == 'K' || k[0] == 'L' || k[0] == 'U'));
if (isZKey || isTKey) {
if (!keyTrim.empty()) {
if (keyRecognized) {
ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine(0, 4.0f);
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s",
isZKey ? "Shielded spending key" : "Transparent private key");
keyIsZ ? "Shielded spending key" : "Transparent private key");
} else {
ImGui::PushFont(ui::material::Type().iconSmall());
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), ICON_MD_HELP);
@@ -2686,18 +2690,17 @@ void App::renderImportKeyDialog()
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))) {
std::string key(import_key_input_);
if (!key.empty()) {
importPrivateKey(key, [this](bool success, const std::string& msg) {
import_success_ = success;
import_status_ = msg;
if (success) {
memset(import_key_input_, 0, sizeof(import_key_input_));
}
});
}
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::EndDisabled();
ImGui::SameLine();
if (ui::material::StyledButton("Close", ImVec2(btnW, 0), ui::material::resolveButtonFont(btnFont))) {
show_import_key_ = false;
@@ -3187,6 +3190,13 @@ void App::rescanBlockchain()
return;
}
// Re-entrancy guard: a rescan/repair (both drive state_.sync.rescanning) or this exact task already
// running would stomp each other — a second confirm must not launch a duplicate operation.
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
@@ -3229,6 +3239,11 @@ void App::repairWallet()
return;
}
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
@@ -3270,6 +3285,11 @@ void App::reinstallBundledDaemon()
return;
}
if (async_tasks_.isRunning("reinstall-daemon")) {
ui::Notifications::instance().warning("The daemon reinstall is already in progress.");
return;
}
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart...");
@@ -3309,6 +3329,11 @@ void App::deleteBlockchainData()
return;
}
if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) {
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
ui::Notifications::instance().info("Stopping daemon and deleting blockchain data...");