LiteWalletGateway::refresh() aborted the entire refresh on the first command whose bridge call or parse failed — which turned a single real-backend shape mismatch (e.g. syncstatus) into a total, empty-everything refresh. Since the balance/addresses/list real shapes are still unverified and we've already hit shape drift twice, make refresh resilient: - Run every planned command; assembleLiteWalletRefreshBundle already skips failed results. - result.ok = any usable data came back (bundle.complete still reflects all-succeeded). - One command's failure now degrades gracefully — the other sections still populate. testLiteWalletGatewayRefreshSkipsFailedCommand (fake balance returns invalid JSON) asserts the refresh still succeeds with addresses/transactions/info populated and balance skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
418 lines
16 KiB
C++
418 lines
16 KiB
C++
#include "wallet/lite_wallet_gateway.h"
|
|
|
|
#include <cctype>
|
|
#include <utility>
|
|
|
|
namespace dragonx::wallet {
|
|
namespace {
|
|
|
|
std::string trimGatewayCopy(const std::string& value)
|
|
{
|
|
auto begin = value.begin();
|
|
while (begin != value.end() && std::isspace(static_cast<unsigned char>(*begin))) ++begin;
|
|
|
|
auto end = value.end();
|
|
while (end != begin && std::isspace(static_cast<unsigned char>(*(end - 1)))) --end;
|
|
|
|
return std::string(begin, end);
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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.
|
|
for (const auto& commandPlan : result.plan.commands) {
|
|
result.commandResults.push_back(executePlannedCommand(commandPlan));
|
|
}
|
|
|
|
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 = trimGatewayCopy(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.status = makeGatewayStatus(WalletBackendState::Error, "lite wallet gateway bridge call failed");
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
return parseBridgeResponse(plan, bridgeCall.value);
|
|
}
|
|
|
|
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
|