Files
ObsidianDragon/src/wallet/lite_wallet_gateway.cpp
T
DanSandClaude Opus 5.5 55975901a2 perf(lite): move wallet ops and server switches off the UI thread
New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).

- The four ops run one at a time on a background lane and their results
  are applied on the main thread; Settings shows "Working…" meanwhile.
  Key and passphrase copies are wiped after the call, or when a queued
  op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
  first scan starts, so the save takes milliseconds and the scan runs on
  the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
  disabled while a scan runs ("Wait for blockchain to sync"); Redownload
  blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
  dropped, pending ones reported failed, its client shut down) and
  destroys it on a background thread. It no longer saves on the way out:
  the new client re-scans the blocks since the last save, as after a
  restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
  finish and save on the old client while the new one owns the wallet
  file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
  server reads once one fails, and then retries after 4, 8, then 15 s
  instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.

Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:09:48 -05:00

451 lines
17 KiB
C++

#include "wallet/lite_wallet_gateway.h"
#include <utility>
namespace dragonx::wallet {
namespace {
WalletBackendStatus makeGatewayStatus(WalletBackendState state, std::string message)
{
WalletBackendStatus status;
status.state = state;
status.message = std::move(message);
return status;
}
WalletBackendStatus refreshStatusForRequest(const LiteWalletRefreshRequest& request)
{
if (request.haveSyncStatus) return walletStatusFromLiteSyncStatus(request.syncStatus);
return makeGatewayStatus(
WalletBackendState::Disconnected,
"lite wallet refresh bundle assembled; runtime wallet state is unchanged");
}
LiteWalletGatewayCommandResult parsedCommandResult(const LiteWalletGatewayPlan& plan)
{
LiteWalletGatewayCommandResult result;
result.ok = true;
result.attempted = true;
result.bridgeAccepted = true;
result.plan = plan;
result.parserError = LiteResultParserError::None;
result.status = makeGatewayStatus(
WalletBackendState::Disconnected,
"lite gateway response parsed into intermediate refresh model");
result.bridgeResponseRedacted = "<redacted>";
return result;
}
} // namespace
const char* liteWalletGatewayCommandName(LiteWalletGatewayCommand command)
{
switch (command) {
case LiteWalletGatewayCommand::Info: return "info";
case LiteWalletGatewayCommand::Height: return "height";
case LiteWalletGatewayCommand::Balance: return "balance";
case LiteWalletGatewayCommand::Addresses: return "addresses";
case LiteWalletGatewayCommand::Notes: return "notes";
case LiteWalletGatewayCommand::List: return "list";
}
return "unknown";
}
bool liteResponseIsBackendError(const std::string& response)
{
for (char c : response) {
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') continue;
return c != '{' && c != '[';
}
return true; // empty
}
bool liteGatewayCommandReachesServer(LiteWalletGatewayCommand command)
{
switch (command) {
case LiteWalletGatewayCommand::Info:
case LiteWalletGatewayCommand::Balance:
case LiteWalletGatewayCommand::Notes:
case LiteWalletGatewayCommand::List: return true;
case LiteWalletGatewayCommand::Height:
case LiteWalletGatewayCommand::Addresses: return false;
}
return false;
}
int liteRefreshDelayMs(int consecutiveFailures, int baseMs)
{
constexpr long long kMaxMs = 15000;
long long ms = baseMs > 0 ? baseMs : 2000;
for (int i = 0; i < consecutiveFailures && ms < kMaxMs; ++i) ms *= 2;
return static_cast<int>(ms < kMaxMs ? ms : kMaxMs);
}
const char* liteWalletGatewayAvailabilityName(LiteWalletGatewayAvailability availability)
{
switch (availability) {
case LiteWalletGatewayAvailability::Ready: return "Ready";
case LiteWalletGatewayAvailability::UnsupportedBuild: return "UnsupportedBuild";
case LiteWalletGatewayAvailability::BackendUnavailable: return "BackendUnavailable";
case LiteWalletGatewayAvailability::BridgeUnavailable: return "BridgeUnavailable";
case LiteWalletGatewayAvailability::BridgeCallsDisabled: return "BridgeCallsDisabled";
case LiteWalletGatewayAvailability::NoUsableServer: return "NoUsableServer";
}
return "Unknown";
}
std::vector<LiteWalletGatewayCommand> liteWalletRefreshCommands(const LiteWalletRefreshRequest& request)
{
std::vector<LiteWalletGatewayCommand> commands;
if (request.includeInfo) commands.push_back(LiteWalletGatewayCommand::Info);
if (request.includeHeight) commands.push_back(LiteWalletGatewayCommand::Height);
if (request.includeBalance) commands.push_back(LiteWalletGatewayCommand::Balance);
if (request.includeAddresses) commands.push_back(LiteWalletGatewayCommand::Addresses);
if (request.includeNotes) commands.push_back(LiteWalletGatewayCommand::Notes);
if (request.includeTransactions) commands.push_back(LiteWalletGatewayCommand::List);
return commands;
}
LiteWalletRefreshBundle assembleLiteWalletRefreshBundle(const std::vector<LiteWalletGatewayCommandResult>& results,
const LiteWalletRefreshRequest& request)
{
LiteWalletRefreshBundle bundle;
if (request.haveSyncStatus) {
bundle.hasSyncStatus = true;
bundle.syncStatus = request.syncStatus;
}
for (const auto& result : results) {
if (!result.ok) continue;
++bundle.successfulCommandCount;
switch (result.plan.command) {
case LiteWalletGatewayCommand::Info:
bundle.hasInfo = true;
bundle.info = result.info;
break;
case LiteWalletGatewayCommand::Height:
bundle.hasHeight = true;
bundle.height = result.height;
break;
case LiteWalletGatewayCommand::Balance:
bundle.hasBalance = true;
bundle.balance = result.balance;
break;
case LiteWalletGatewayCommand::Addresses:
bundle.hasAddresses = true;
bundle.addresses = result.addresses;
break;
case LiteWalletGatewayCommand::Notes:
bundle.hasNotes = true;
bundle.notes = result.notes;
break;
case LiteWalletGatewayCommand::List:
bundle.hasTransactions = true;
bundle.transactions = result.transactions;
break;
}
}
const auto expectedCommands = liteWalletRefreshCommands(request);
bundle.complete = bundle.successfulCommandCount == expectedCommands.size();
return bundle;
}
LiteWalletGateway::LiteWalletGateway(WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings,
LiteClientBridge* bridge,
LiteWalletGatewayOptions options)
: capabilities_(capabilities),
connectionSettings_(std::move(connectionSettings)),
bridge_(bridge),
options_(options)
{
}
LiteWalletGatewayAvailability LiteWalletGateway::availability() const
{
if (!isLiteBuild(capabilities_)) return LiteWalletGatewayAvailability::UnsupportedBuild;
if (!supportsLiteBackend(capabilities_)) return LiteWalletGatewayAvailability::BackendUnavailable;
if (!bridge_ || !bridge_->available()) return LiteWalletGatewayAvailability::BridgeUnavailable;
if (!selectLiteServer(connectionSettings_).ok) return LiteWalletGatewayAvailability::NoUsableServer;
if (!options_.allowBridgeCalls) return LiteWalletGatewayAvailability::BridgeCallsDisabled;
return LiteWalletGatewayAvailability::Ready;
}
WalletBackendStatus LiteWalletGateway::status() const
{
const auto currentAvailability = availability();
if (currentAvailability == LiteWalletGatewayAvailability::NoUsableServer) {
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
}
return statusFor(currentAvailability);
}
LiteWalletGatewayPlan LiteWalletGateway::planCommand(const LiteWalletGatewayRequest& request) const
{
LiteWalletGatewayPlan plan;
plan.command = request.command;
plan.commandName = liteWalletGatewayCommandName(request.command);
plan.bridgeExecutionAllowed = options_.allowBridgeCalls;
auto selection = selectServerForRequest(request.serverUrl);
if (!selection.ok) {
plan.error = selection.error;
return plan;
}
plan.ok = true;
plan.server = selection.server;
plan.serverIndex = selection.serverIndex;
plan.customServer = selection.customServer;
return plan;
}
LiteWalletRefreshPlan LiteWalletGateway::planRefresh(const LiteWalletRefreshRequest& request) const
{
LiteWalletRefreshPlan plan;
const auto commands = liteWalletRefreshCommands(request);
if (commands.empty()) {
plan.error = "lite refresh request has no commands";
return plan;
}
for (const auto command : commands) {
const auto commandPlan = planCommand(LiteWalletGatewayRequest{command, request.serverUrl});
if (!commandPlan.ok) {
plan.error = commandPlan.error;
return plan;
}
plan.commands.push_back(commandPlan);
}
plan.ok = true;
return plan;
}
LiteWalletGatewayCommandResult LiteWalletGateway::fetchCommand(const LiteWalletGatewayRequest& request)
{
const auto plan = planCommand(request);
if (!plan.ok) return blockedCommandResult(plan, makeGatewayStatus(WalletBackendState::Error, plan.error));
if (availability() != LiteWalletGatewayAvailability::Ready) return blockedCommandResult(plan, status());
return executePlannedCommand(plan);
}
LiteWalletRefreshResult LiteWalletGateway::refresh(const LiteWalletRefreshRequest& request)
{
LiteWalletRefreshResult result;
result.plan = planRefresh(request);
if (!result.plan.ok) {
result.status = makeGatewayStatus(WalletBackendState::Error, result.plan.error);
result.error = result.status.message;
return result;
}
if (availability() != LiteWalletGatewayAvailability::Ready) {
result.status = status();
result.error = result.status.message;
return result;
}
result.attempted = true;
// Run every planned command. A single command's bridge or parse failure must NOT abort the
// whole refresh — partial data beats none, and real-backend response shapes can drift per
// command. assembleLiteWalletRefreshBundle() ignores the failed results.
bool syncReadFailed = false;
for (const auto& commandPlan : result.plan.commands) {
if (request.keepGoing && !request.keepGoing()) { result.cancelled = true; break; }
const bool serverBound = liteGatewayCommandReachesServer(commandPlan.command);
if (serverBound && syncReadFailed && request.stopSyncReadsOnFailure) continue; // skipped, not failed
auto commandResult = executePlannedCommand(commandPlan);
if (serverBound && commandResult.backendError) syncReadFailed = true;
result.commandResults.push_back(std::move(commandResult));
}
result.syncReadFailed = syncReadFailed;
if (result.cancelled) {
result.status = makeGatewayStatus(WalletBackendState::Disconnected, "lite refresh cancelled");
result.error = result.status.message;
return result;
}
result.bundle = assembleLiteWalletRefreshBundle(result.commandResults, request);
// Succeed if anything usable came back; some commands may have been skipped. (bundle.complete
// still reflects whether *all* commands succeeded, for callers that care.)
result.ok = result.bundle.successfulCommandCount > 0 || result.bundle.hasSyncStatus;
if (!result.ok) {
for (const auto& commandResult : result.commandResults) {
if (!commandResult.ok) {
result.status = commandResult.status;
result.error = commandResult.error;
break;
}
}
if (result.error.empty()) {
result.status = makeGatewayStatus(WalletBackendState::Error, "lite refresh produced no usable data");
result.error = result.status.message;
}
return result;
}
result.status = refreshStatusForRequest(request);
return result;
}
LiteServerSelectionResult LiteWalletGateway::selectServerForRequest(const std::string& serverUrl) const
{
const auto overrideUrl = liteTrimCopy(serverUrl);
if (!overrideUrl.empty()) {
if (!isLiteServerUrlUsable(overrideUrl)) {
return LiteServerSelectionResult{false, {}, 0, false, "lite gateway server URL is not usable"};
}
return LiteServerSelectionResult{
true,
LiteServerEndpoint{overrideUrl, "Request", true},
0,
true,
{}
};
}
return selectLiteServer(connectionSettings_);
}
WalletBackendStatus LiteWalletGateway::statusFor(LiteWalletGatewayAvailability availability,
const std::string& detail) const
{
switch (availability) {
case LiteWalletGatewayAvailability::Ready:
return makeGatewayStatus(
WalletBackendState::Disconnected,
detail.empty() ? "lite wallet gateway scaffold ready; refresh execution is fake-test only" : detail);
case LiteWalletGatewayAvailability::UnsupportedBuild:
return makeGatewayStatus(
WalletBackendState::Unavailable,
"lite wallet gateway is unsupported in full-node builds");
case LiteWalletGatewayAvailability::BackendUnavailable:
return makeGatewayStatus(
WalletBackendState::Unavailable,
"lite backend is not linked");
case LiteWalletGatewayAvailability::BridgeUnavailable:
return makeGatewayStatus(
WalletBackendState::Unavailable,
detail.empty() ? (bridge_ ? bridge_->unavailableReason() : "lite bridge is unavailable") : detail);
case LiteWalletGatewayAvailability::BridgeCallsDisabled:
return makeGatewayStatus(
WalletBackendState::Unavailable,
"lite wallet gateway bridge calls are disabled");
case LiteWalletGatewayAvailability::NoUsableServer:
return makeGatewayStatus(
WalletBackendState::Error,
detail.empty() ? "no usable lite servers are configured" : detail);
}
return makeGatewayStatus(WalletBackendState::Unavailable, "unknown lite wallet gateway state");
}
LiteWalletGatewayCommandResult LiteWalletGateway::blockedCommandResult(
const LiteWalletGatewayPlan& plan,
const WalletBackendStatus& blockedStatus) const
{
LiteWalletGatewayCommandResult result;
result.plan = plan;
result.status = blockedStatus;
result.error = blockedStatus.message;
return result;
}
LiteWalletGatewayCommandResult LiteWalletGateway::executePlannedCommand(const LiteWalletGatewayPlan& plan)
{
LiteWalletGatewayCommandResult result;
result.plan = plan;
result.attempted = true;
const auto bridgeCall = bridge_->execute(plan.commandName, plan.args);
result.bridgeResponseRedacted = bridgeCall.ok || !bridgeCall.error.empty() ? "<redacted>" : "<empty>";
if (!bridgeCall.ok) {
result.backendError = true;
result.status = makeGatewayStatus(WalletBackendState::Error, "lite wallet gateway bridge call failed");
result.error = result.status.message;
return result;
}
auto parsed = parseBridgeResponse(plan, bridgeCall.value);
parsed.backendError = liteResponseIsBackendError(bridgeCall.value);
return parsed;
}
LiteWalletGatewayCommandResult LiteWalletGateway::parseBridgeResponse(
const LiteWalletGatewayPlan& plan,
const std::string& response) const
{
auto result = parsedCommandResult(plan);
switch (plan.command) {
case LiteWalletGatewayCommand::Info: {
const auto parsed = parseLiteInfoResponse(response);
if (!parsed.ok) {
result.ok = false;
result.parserError = parsed.error;
break;
}
result.info = parsed.info;
return result;
}
case LiteWalletGatewayCommand::Height: {
const auto parsed = parseLiteHeightResponse(response);
if (!parsed.ok) {
result.ok = false;
result.parserError = parsed.error;
break;
}
result.height = parsed.height;
return result;
}
case LiteWalletGatewayCommand::Balance: {
const auto parsed = parseLiteBalanceResponse(response);
if (!parsed.ok) {
result.ok = false;
result.parserError = parsed.error;
break;
}
result.balance = parsed.balance;
return result;
}
case LiteWalletGatewayCommand::Addresses: {
const auto parsed = parseLiteAddressesResponse(response);
if (!parsed.ok) {
result.ok = false;
result.parserError = parsed.error;
break;
}
result.addresses = parsed.addresses;
return result;
}
case LiteWalletGatewayCommand::Notes: {
const auto parsed = parseLiteNotesResponse(response);
if (!parsed.ok) {
result.ok = false;
result.parserError = parsed.error;
break;
}
result.notes = parsed.notes;
return result;
}
case LiteWalletGatewayCommand::List: {
const auto parsed = parseLiteTransactionsResponse(response);
if (!parsed.ok) {
result.ok = false;
result.parserError = parsed.error;
break;
}
result.transactions = parsed.transactions;
return result;
}
}
result.bridgeAccepted = true;
result.status = makeGatewayStatus(WalletBackendState::Error, "lite wallet gateway response could not be parsed");
result.error = result.status.message;
result.bridgeResponseRedacted = "<redacted>";
return result;
}
} // namespace dragonx::wallet