A failed wallet unlock was always treated as a wrong guess: the lockout counter was bumped and the back-off curve armed no matter why the RPC failed. A busy or warming-up node, a transport timeout, or a wallet that is not actually encrypted would all eat an attempt and could push a user into a lockout without a single mistyped passphrase. The failed call is now classified before it is acted on: - Only the daemon's "passphrase incorrect" error counts as a wrong guess and advances the lockout. - A warming-up or unreachable node, a reply timeout, or a full queue is reported as a transient busy-node condition with no attempt charged. - A "wallet is not encrypted" reply refreshes the local encryption state instead of penalising the user. - Any unknown error surfaces its message without touching the counter. The PIN path got the same treatment: when the vault decrypts correctly but the unlock RPC then fails for a non-credential reason, the attempt is no longer counted. Tests cover the classification of each failure kind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
8.3 KiB
C++
182 lines
8.3 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#pragma once
|
|
|
|
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
|
|
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
|
|
// test target can use it without compiling rpc_client.cpp.
|
|
|
|
#include <curl/curl.h>
|
|
|
|
#include <stdexcept>
|
|
#include <string>
|
|
|
|
namespace dragonx {
|
|
namespace rpc {
|
|
|
|
enum class RpcFailureKind {
|
|
None, // the call succeeded
|
|
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
|
|
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
|
|
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
|
|
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
|
|
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
|
|
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
|
|
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
|
|
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
|
|
AppError, // the daemon ran the method and answered with a JSON-RPC error
|
|
Aborted, // our own requestAbort() cut the transfer
|
|
Deferred, // not sent: the node is busy (RpcBusyGate), so we held back rather than queue more
|
|
Other,
|
|
};
|
|
|
|
inline const char* rpcFailureKindName(RpcFailureKind kind)
|
|
{
|
|
switch (kind) {
|
|
case RpcFailureKind::None: return "None";
|
|
case RpcFailureKind::NotListening: return "NotListening";
|
|
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
|
|
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
|
|
case RpcFailureKind::QueueFull: return "QueueFull";
|
|
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
|
|
case RpcFailureKind::Unhandled: return "Unhandled";
|
|
case RpcFailureKind::Auth: return "Auth";
|
|
case RpcFailureKind::Warmup: return "Warmup";
|
|
case RpcFailureKind::AppError: return "AppError";
|
|
case RpcFailureKind::Aborted: return "Aborted";
|
|
case RpcFailureKind::Deferred: return "Deferred";
|
|
case RpcFailureKind::Other: return "Other";
|
|
}
|
|
return "Other";
|
|
}
|
|
|
|
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
|
|
inline bool isRpcTimeout(RpcFailureKind kind)
|
|
{
|
|
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
|
|
}
|
|
|
|
/**
|
|
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
|
|
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
|
|
* connect- and reply-phase timeouts with the same code; this tells them apart.
|
|
* @param httpStatus HTTP status, 0 when there was no response
|
|
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
|
|
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
|
|
*/
|
|
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
|
|
const std::string& plainBody)
|
|
{
|
|
if (curlCode != CURLE_OK) {
|
|
switch (curlCode) {
|
|
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
|
|
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
|
|
case CURLE_OPERATION_TIMEDOUT:
|
|
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
|
|
default: return RpcFailureKind::Other;
|
|
}
|
|
}
|
|
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
|
|
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
|
|
if (httpStatus == 200) return RpcFailureKind::None;
|
|
if (httpStatus == 401) return RpcFailureKind::Auth;
|
|
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
|
|
if (httpStatus == 500) {
|
|
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
|
|
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
|
|
}
|
|
return RpcFailureKind::Other;
|
|
}
|
|
|
|
/**
|
|
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
|
|
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
|
|
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
|
|
*/
|
|
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
|
|
{
|
|
std::string text;
|
|
bool pendingSpace = false;
|
|
for (unsigned char c : plainBody) {
|
|
if (text.size() >= 120) break;
|
|
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
|
|
pendingSpace = !text.empty();
|
|
} else if (c >= 0x21 && c <= 0x7e) {
|
|
if (pendingSpace && text.size() < 119) text.push_back(' ');
|
|
pendingSpace = false;
|
|
text.push_back(static_cast<char>(c));
|
|
}
|
|
}
|
|
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
|
|
if (!text.empty()) out += ": " + text;
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
|
|
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
|
|
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
|
|
*/
|
|
class RpcError : public std::runtime_error {
|
|
public:
|
|
RpcError(int errorCode, const std::string& message, long status = 0)
|
|
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
|
|
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
|
|
int code = 0;
|
|
long httpStatus = 0;
|
|
};
|
|
|
|
/**
|
|
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
|
|
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
|
|
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
|
|
*/
|
|
class RpcTransportError : public std::runtime_error {
|
|
public:
|
|
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
|
|
long status = 0, bool sent = false)
|
|
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
|
|
RpcFailureKind kind = RpcFailureKind::Other;
|
|
int curlCode = CURLE_OK;
|
|
long httpStatus = 0;
|
|
bool requestSent = false;
|
|
};
|
|
|
|
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
|
|
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
|
{
|
|
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
|
|
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
|
|
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<const RpcError*>(&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
|