diff --git a/res/lang/de.json b/res/lang/de.json index 76f21e0..ae9a24d 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds", "sec_unlock_button": "Entsperren", "sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "Entsperren%s", "sec_use_passphrase_instead": "Stattdessen Passphrase verwenden", "sec_use_pin_instead": "Stattdessen PIN verwenden", diff --git a/res/lang/es.json b/res/lang/es.json index 25a11ed..d87ed03 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "Tiempo total: %dm %02ds", "sec_unlock_button": "Desbloquear", "sec_unlock_failed_prefix": "Error al desbloquear: ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "Desbloqueando%s", "sec_use_passphrase_instead": "Usar frase de contraseña en su lugar", "sec_use_pin_instead": "Usar PIN en su lugar", diff --git a/res/lang/fr.json b/res/lang/fr.json index 33ab00a..b038317 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds", "sec_unlock_button": "Déverrouiller", "sec_unlock_failed_prefix": "Échec du déverrouillage : ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "Déverrouillage%s", "sec_use_passphrase_instead": "Utiliser une phrase secrète", "sec_use_pin_instead": "Utiliser un PIN", diff --git a/res/lang/ja.json b/res/lang/ja.json index 2398feb..60c5a6e 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒", "sec_unlock_button": "ロック解除", "sec_unlock_failed_prefix": "ロック解除に失敗しました: ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "ロック解除中%s", "sec_use_passphrase_instead": "代わりにパスフレーズを使用", "sec_use_pin_instead": "代わりにPINを使用", diff --git a/res/lang/ko.json b/res/lang/ko.json index b3cea3e..5cbc6f8 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초", "sec_unlock_button": "잠금 해제", "sec_unlock_failed_prefix": "잠금 해제 실패: ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "잠금 해제 중%s", "sec_use_passphrase_instead": "대신 암호문 사용", "sec_use_pin_instead": "대신 PIN 사용", diff --git a/res/lang/pt.json b/res/lang/pt.json index 60fcc77..6521540 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "Tempo total: %dm %02ds", "sec_unlock_button": "Desbloquear", "sec_unlock_failed_prefix": "Falha ao desbloquear: ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "Desbloqueando%s", "sec_use_passphrase_instead": "Usar frase-senha", "sec_use_pin_instead": "Usar PIN", diff --git a/res/lang/ru.json b/res/lang/ru.json index 244ec2a..b0cdff1 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "Всего прошло: %dм %02dс", "sec_unlock_button": "Разблокировать", "sec_unlock_failed_prefix": "Ошибка разблокировки: ", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "Разблокировка%s", "sec_use_passphrase_instead": "Использовать парольную фразу", "sec_use_pin_instead": "Использовать PIN", diff --git a/res/lang/zh.json b/res/lang/zh.json index 0305b8c..95fa398 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -1707,6 +1707,7 @@ "sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒", "sec_unlock_button": "解锁", "sec_unlock_failed_prefix": "解锁失败:", + "sec_unlock_node_busy": "The node is busy; try again in a moment.", "sec_unlocking_fmt": "正在解锁%s", "sec_use_passphrase_instead": "改用密码短语", "sec_use_pin_instead": "改用 PIN", diff --git a/src/app.h b/src/app.h index ded25d0..1acd56d 100644 --- a/src/app.h +++ b/src/app.h @@ -910,6 +910,8 @@ private: // curve on every failed path (a PIN RPC failure used to skip it). void applyUnlockSuccess(const std::string& passphrase, int timeout); void applyUnlockFailure(const std::string& errorMessage); + // Non-credential failure (busy node, unknown error): message only, no attempt counted. + void applyUnlockNotCounted(const std::string& errorMessage); // Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four // rescan-completion sites so a new witness field can't be forgotten in one copy. void resetWitnessRescanProgress(); diff --git a/src/app_security.cpp b/src/app_security.cpp index 35429e5..2bea442 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -511,6 +511,16 @@ void App::applyUnlockFailure(const std::string& errorMessage) { DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_); } +// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the +// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout. +void App::applyUnlockNotCounted(const std::string& msg) { + lock_unlock_in_progress_ = false; + lock_error_msg_ = msg; + lock_error_timer_ = 3.0f; + lock_timer_armed_at_ = std::chrono::steady_clock::now(); + memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_)); +} + void App::unlockWallet(const std::string& passphrase, int timeout) { if (!rpc_ || !rpc_->isConnected() || !worker_) return; lock_unlock_in_progress_ = true; @@ -520,17 +530,39 @@ void App::unlockWallet(const std::string& passphrase, int timeout) { auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb { std::string err_msg; - WalletSecurityRpcAdapter rpcAdapter(r); - bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg); + rpc::UnlockFailure cls = rpc::UnlockFailure::Other; + bool ok = false; + try { + rpc::RPCClient::TraceScope trace("Security / Unlock"); + r->call("walletpassphrase", {passphrase, timeout}); + ok = true; + } catch (const std::exception& e) { + err_msg = e.what(); + cls = rpc::classifyUnlockFailure(e); + } std::string cachePassphrase = passphrase; util::SecureVault::secureZero(passphrase.data(), passphrase.size()); - return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable { + return [this, ok, cls, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable { if (ok) { applyUnlockSuccess(passphrase, timeout); } else { DEBUG_LOGF("[App] Passphrase unlock RPC error: %s\n", err_msg.c_str()); - applyUnlockFailure(TR("incorrect_passphrase")); + switch (cls) { + case rpc::UnlockFailure::WrongPassphrase: + applyUnlockFailure(TR("incorrect_passphrase")); + break; + case rpc::UnlockFailure::NodeBusy: + applyUnlockNotCounted(TR("sec_unlock_node_busy")); + break; + case rpc::UnlockFailure::NotEncrypted: + applyUnlockNotCounted(TR("sec_unlock_node_busy")); + refreshWalletEncryptionState(); + break; + case rpc::UnlockFailure::Other: + applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + err_msg); + break; + } } util::SecureVault::secureZero(passphrase.data(), passphrase.size()); }; @@ -1375,11 +1407,11 @@ void App::renderLockScreen() { "PIN marked out of date, no penalty\n"); }; } else { - // Vault decrypt succeeded but the RPC failed — still a failed unlock, - // so route through applyUnlockFailure so the lockout curve applies - // (this path previously bumped the counter but skipped the lockout math). + // Vault decrypt succeeded (the PIN was correct) and the RPC failed for a + // reason other than -14 (busy node, warmup, transport error). That is never + // a wrong credential, so it must not count toward the lockout. return [this, rpcErr, passphrase = std::move(passphrase)]() mutable { - applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr); + applyUnlockNotCounted(std::string(TR("sec_unlock_failed_prefix")) + rpcErr); util::SecureVault::secureZero(passphrase.data(), passphrase.size()); }; } diff --git a/src/rpc/rpc_failure.h b/src/rpc/rpc_failure.h index b46733c..1120f65 100644 --- a/src/rpc/rpc_failure.h +++ b/src/rpc/rpc_failure.h @@ -152,5 +152,30 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e) return RpcFailureKind::Other; } +// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout; +// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess. +enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other }; + +inline UnlockFailure classifyUnlockFailure(const std::exception& e) +{ + if (const auto* r = dynamic_cast(&e)) { + if (r->code == -14) return UnlockFailure::WrongPassphrase; // RPC_WALLET_PASSPHRASE_INCORRECT + if (r->code == -15) return UnlockFailure::NotEncrypted; // RPC_WALLET_WRONG_ENC_STATE + if (r->code == -28) return UnlockFailure::NodeBusy; // RPC_IN_WARMUP + return UnlockFailure::Other; + } + switch (rpcFailureKindOf(e)) { + case RpcFailureKind::ReplyTimeout: + case RpcFailureKind::QueueFull: + case RpcFailureKind::Deferred: + case RpcFailureKind::Warmup: + case RpcFailureKind::ConnectTimeout: + case RpcFailureKind::NotListening: + return UnlockFailure::NodeBusy; + default: + return UnlockFailure::Other; + } +} + } // namespace rpc } // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index f160902..e5e22d1 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -2705,6 +2705,7 @@ void I18n::loadBuiltinEnglish() strings_["sec_unlock_button"] = "Unlock"; strings_["sec_not_connected_to_daemon"] = "Not connected to daemon"; strings_["sec_unlock_failed_prefix"] = "Unlock failed: "; + strings_["sec_unlock_node_busy"] = "The node is busy; try again in a moment."; strings_["sec_incorrect_passphrase_decrypt"] = "Incorrect passphrase"; strings_["sec_importing_keys_rescanning"] = "Importing keys & rescanning blockchain — wallet is usable while this runs"; strings_["sec_encrypted_backup_suffix"] = "\nEncrypted backup: wallet.dat.encrypted.bak"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index e49e5c5..bf65b97 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -815,6 +815,29 @@ void testClassifyRpcFailure() EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull)); } +void testClassifyUnlockFailure() +{ + using dragonx::rpc::RpcError; + using dragonx::rpc::RpcTransportError; + using dragonx::rpc::RpcFailureKind; + using dragonx::rpc::UnlockFailure; + using dragonx::rpc::classifyUnlockFailure; + EXPECT_TRUE(classifyUnlockFailure(RpcError(-14, "bad")) == UnlockFailure::WrongPassphrase); + EXPECT_TRUE(classifyUnlockFailure(RpcError(-15, "not enc")) == UnlockFailure::NotEncrypted); + EXPECT_TRUE(classifyUnlockFailure(RpcError(-28, "warmup")) == UnlockFailure::NodeBusy); + EXPECT_TRUE(classifyUnlockFailure(RpcError(-6, "funds")) == UnlockFailure::Other); + const RpcFailureKind busy[] = {RpcFailureKind::ReplyTimeout, RpcFailureKind::QueueFull, + RpcFailureKind::Deferred, RpcFailureKind::ConnectTimeout, RpcFailureKind::NotListening, + RpcFailureKind::Warmup}; + for (auto k : busy) + EXPECT_TRUE(classifyUnlockFailure(RpcTransportError(k, "x")) == UnlockFailure::NodeBusy); + const RpcFailureKind other[] = {RpcFailureKind::ShuttingDown, RpcFailureKind::Unhandled, + RpcFailureKind::Aborted, RpcFailureKind::Auth}; + for (auto k : other) + EXPECT_TRUE(classifyUnlockFailure(RpcTransportError(k, "x")) == UnlockFailure::Other); + EXPECT_TRUE(classifyUnlockFailure(std::runtime_error("x")) == UnlockFailure::Other); +} + void testFormatHttpError() { using dragonx::rpc::formatHttpError; @@ -18189,6 +18212,7 @@ int main() { testConnectionConfig(); testClassifyRpcFailure(); + testClassifyUnlockFailure(); testFormatHttpError(); testRpcFailureKindOf(); testPaymentUri();