Shared-bridge refactor (litelib is a global singleton; every LiteClientBridge calls litelib_shutdown() on destruction, so services must not each own one): - LiteWalletLifecycleService, LiteWalletGateway, LiteSyncService now take a non-owning LiteClientBridge*; LiteWalletController owns the single bridge and passes &bridge_. Sync + controller refresh: - LiteSyncService::startSync executes the real "sync" command (was a stub). - LiteWalletController: startSync() (auto-fires when a wallet becomes ready) and refreshWalletState(WalletState&) — polls syncstatus, runs gateway.refresh(), maps the bundle, applies balances/addresses/transactions/sync into WalletState. Tests: - fake_lite_backend.h returns command-shaped JSON (per tests/fixtures/lite/result_parsers.json). - testLiteWalletControllerRefreshPopulatesState drives the full path against the fake. - Surfaced + worked around a real integration issue: parseLiteInfoResponse requires latest_block_height and the gateway aborts the whole refresh on the first command's parse failure (fragile vs partial backend responses; hardening tracked for M2b-3). Verified: ctest green; lite+backend, full-node, lite-no-backend apps + lite_smoke build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
383 lines
14 KiB
C++
383 lines
14 KiB
C++
#include "wallet/lite_sync_service.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <limits>
|
|
#include <utility>
|
|
|
|
namespace dragonx::wallet {
|
|
namespace {
|
|
|
|
std::string trimSyncCopy(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);
|
|
}
|
|
|
|
std::uint64_t effectiveStagnantThreshold(std::uint64_t threshold)
|
|
{
|
|
return threshold == 0 ? 1 : threshold;
|
|
}
|
|
|
|
WalletBackendStatus makeSyncScaffoldStatus(WalletBackendState state,
|
|
std::string message,
|
|
const LiteSyncStatusResponse* syncStatus = nullptr)
|
|
{
|
|
WalletBackendStatus status;
|
|
status.state = state;
|
|
status.message = std::move(message);
|
|
if (syncStatus) {
|
|
status.walletHeight = static_cast<int>(std::min<std::uint64_t>(syncStatus->syncedBlocks, static_cast<std::uint64_t>(std::numeric_limits<int>::max())));
|
|
status.chainHeight = static_cast<int>(std::min<std::uint64_t>(syncStatus->totalBlocks, static_cast<std::uint64_t>(std::numeric_limits<int>::max())));
|
|
status.syncProgress = syncStatus->progress;
|
|
}
|
|
return status;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
const char* liteSyncOperationName(LiteSyncOperation operation)
|
|
{
|
|
switch (operation) {
|
|
case LiteSyncOperation::StartSync: return "StartSync";
|
|
case LiteSyncOperation::PollSyncStatus: return "PollSyncStatus";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
const char* liteSyncStartModeName(LiteSyncStartMode mode)
|
|
{
|
|
switch (mode) {
|
|
case LiteSyncStartMode::Startup: return "Startup";
|
|
case LiteSyncStartMode::Restore: return "Restore";
|
|
case LiteSyncStartMode::Rescan: return "Rescan";
|
|
case LiteSyncStartMode::Recovery: return "Recovery";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
const char* liteSyncAvailabilityName(LiteSyncAvailability availability)
|
|
{
|
|
switch (availability) {
|
|
case LiteSyncAvailability::Ready: return "Ready";
|
|
case LiteSyncAvailability::UnsupportedBuild: return "UnsupportedBuild";
|
|
case LiteSyncAvailability::BackendUnavailable: return "BackendUnavailable";
|
|
case LiteSyncAvailability::BridgeUnavailable: return "BridgeUnavailable";
|
|
case LiteSyncAvailability::BridgeCallsDisabled: return "BridgeCallsDisabled";
|
|
case LiteSyncAvailability::NoUsableServer: return "NoUsableServer";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
const char* liteSyncRecoveryDecisionKindName(LiteSyncRecoveryDecisionKind kind)
|
|
{
|
|
switch (kind) {
|
|
case LiteSyncRecoveryDecisionKind::KeepPolling: return "KeepPolling";
|
|
case LiteSyncRecoveryDecisionKind::SyncComplete: return "SyncComplete";
|
|
case LiteSyncRecoveryDecisionKind::Stuck: return "Stuck";
|
|
case LiteSyncRecoveryDecisionKind::ReorgDetected: return "ReorgDetected";
|
|
case LiteSyncRecoveryDecisionKind::InvalidStatus: return "InvalidStatus";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
WalletBackendStatus walletStatusFromLiteSyncStatus(const LiteSyncStatusResponse& syncStatus)
|
|
{
|
|
if (syncStatus.complete) {
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Ready,
|
|
"lite syncstatus reports sync complete",
|
|
&syncStatus);
|
|
}
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Syncing,
|
|
"lite syncstatus reports sync in progress",
|
|
&syncStatus);
|
|
}
|
|
|
|
LiteSyncRecoveryDecision evaluateLiteSyncRecovery(const LiteSyncRecoveryInput& input)
|
|
{
|
|
if (input.totalBlocks > 0 && input.syncedBlocks > input.totalBlocks) {
|
|
return LiteSyncRecoveryDecision{
|
|
LiteSyncRecoveryDecisionKind::InvalidStatus,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
"synced block count is greater than total block count"
|
|
};
|
|
}
|
|
|
|
if (input.havePreviousSyncedBlocks && input.syncedBlocks < input.previousSyncedBlocks) {
|
|
return LiteSyncRecoveryDecision{
|
|
LiteSyncRecoveryDecisionKind::ReorgDetected,
|
|
false,
|
|
true,
|
|
true,
|
|
true,
|
|
false,
|
|
"synced block count moved backwards; model clear/rescan recovery"
|
|
};
|
|
}
|
|
|
|
if (input.totalBlocks > 0 && input.syncedBlocks >= input.totalBlocks) {
|
|
return LiteSyncRecoveryDecision{
|
|
LiteSyncRecoveryDecisionKind::SyncComplete,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
"syncstatus reports all blocks synced"
|
|
};
|
|
}
|
|
|
|
const auto threshold = effectiveStagnantThreshold(input.stagnantPollThreshold);
|
|
if (input.havePreviousSyncedBlocks &&
|
|
input.syncedBlocks == input.previousSyncedBlocks &&
|
|
input.stagnantPollCount >= threshold) {
|
|
return LiteSyncRecoveryDecision{
|
|
LiteSyncRecoveryDecisionKind::Stuck,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
false,
|
|
"synced block count has not advanced past the configured threshold"
|
|
};
|
|
}
|
|
|
|
return LiteSyncRecoveryDecision{
|
|
LiteSyncRecoveryDecisionKind::KeepPolling,
|
|
true,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
"syncstatus is usable; keep polling"
|
|
};
|
|
}
|
|
|
|
LiteSyncService::LiteSyncService(WalletCapabilities capabilities,
|
|
LiteConnectionSettings connectionSettings,
|
|
LiteClientBridge* bridge,
|
|
LiteSyncServiceOptions options)
|
|
: capabilities_(capabilities),
|
|
connectionSettings_(std::move(connectionSettings)),
|
|
bridge_(bridge),
|
|
options_(options)
|
|
{
|
|
}
|
|
|
|
LiteSyncAvailability LiteSyncService::availability() const
|
|
{
|
|
if (!isLiteBuild(capabilities_)) return LiteSyncAvailability::UnsupportedBuild;
|
|
if (!supportsLiteBackend(capabilities_)) return LiteSyncAvailability::BackendUnavailable;
|
|
if (!bridge_ || !bridge_->available()) return LiteSyncAvailability::BridgeUnavailable;
|
|
if (!selectLiteServer(connectionSettings_).ok) return LiteSyncAvailability::NoUsableServer;
|
|
if (!options_.allowSyncStatusBridgeCalls) return LiteSyncAvailability::BridgeCallsDisabled;
|
|
return LiteSyncAvailability::Ready;
|
|
}
|
|
|
|
WalletBackendStatus LiteSyncService::status() const
|
|
{
|
|
const auto currentAvailability = availability();
|
|
if (currentAvailability == LiteSyncAvailability::NoUsableServer) {
|
|
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
|
|
}
|
|
return statusFor(currentAvailability);
|
|
}
|
|
|
|
LiteSyncPlan LiteSyncService::planStartSync(const LiteSyncStartRequest& request) const
|
|
{
|
|
return makePlan(
|
|
LiteSyncOperation::StartSync,
|
|
request.serverUrl,
|
|
options_.allowSyncStatusBridgeCalls,
|
|
request.mode,
|
|
request.forceRescan,
|
|
request.afterRestore);
|
|
}
|
|
|
|
LiteSyncPlan LiteSyncService::planSyncStatus(const LiteSyncStatusRequest& request) const
|
|
{
|
|
return makePlan(
|
|
LiteSyncOperation::PollSyncStatus,
|
|
request.serverUrl,
|
|
options_.allowSyncStatusBridgeCalls,
|
|
LiteSyncStartMode::Startup,
|
|
false,
|
|
false);
|
|
}
|
|
|
|
LiteSyncStartResult LiteSyncService::startSync(const LiteSyncStartRequest& request)
|
|
{
|
|
const auto plan = planStartSync(request);
|
|
if (!plan.ok) return blockedStartResult(plan, makeSyncScaffoldStatus(WalletBackendState::Error, plan.error));
|
|
|
|
const auto currentAvailability = availability();
|
|
if (currentAvailability == LiteSyncAvailability::UnsupportedBuild ||
|
|
currentAvailability == LiteSyncAvailability::BackendUnavailable ||
|
|
currentAvailability == LiteSyncAvailability::BridgeUnavailable ||
|
|
currentAvailability == LiteSyncAvailability::NoUsableServer) {
|
|
return blockedStartResult(plan, status());
|
|
}
|
|
|
|
LiteSyncStartResult result;
|
|
result.plan = plan;
|
|
result.attempted = true;
|
|
|
|
const auto bridgeCall = bridge_->execute(plan.command, plan.args);
|
|
if (!bridgeCall.ok) {
|
|
result.status = makeSyncScaffoldStatus(WalletBackendState::Error, "lite sync start bridge call failed");
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
result.ok = true;
|
|
result.syncStarted = true;
|
|
result.status = makeSyncScaffoldStatus(WalletBackendState::Syncing, "lite sync started");
|
|
return result;
|
|
}
|
|
|
|
LiteSyncStatusResult LiteSyncService::pollSyncStatus(const LiteSyncStatusRequest& request)
|
|
{
|
|
const auto plan = planSyncStatus(request);
|
|
if (!plan.ok) return blockedStatusResult(plan, makeSyncScaffoldStatus(WalletBackendState::Error, plan.error));
|
|
|
|
if (availability() != LiteSyncAvailability::Ready) return blockedStatusResult(plan, status());
|
|
|
|
LiteSyncStatusResult result;
|
|
result.plan = plan;
|
|
result.attempted = true;
|
|
|
|
const auto bridgeCall = bridge_->execute(plan.command, plan.args);
|
|
result.bridgeResponseRedacted = bridgeCall.ok || !bridgeCall.error.empty() ? "<redacted>" : "<empty>";
|
|
if (!bridgeCall.ok) {
|
|
result.status = makeSyncScaffoldStatus(WalletBackendState::Error, "lite syncstatus bridge call failed");
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
result.bridgeAccepted = true;
|
|
const auto parsed = parseLiteSyncStatusResponse(bridgeCall.value);
|
|
if (!parsed.ok) {
|
|
result.parserError = parsed.error;
|
|
result.status = makeSyncScaffoldStatus(WalletBackendState::Error, "lite syncstatus response could not be parsed");
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
result.ok = true;
|
|
result.parserError = LiteResultParserError::None;
|
|
result.syncStatus = parsed.syncStatus;
|
|
result.status = walletStatusFromLiteSyncStatus(result.syncStatus);
|
|
return result;
|
|
}
|
|
|
|
LiteServerSelectionResult LiteSyncService::selectServerForRequest(const std::string& serverUrl) const
|
|
{
|
|
const auto overrideUrl = trimSyncCopy(serverUrl);
|
|
if (!overrideUrl.empty()) {
|
|
if (!isLiteServerUrlUsable(overrideUrl)) {
|
|
return LiteServerSelectionResult{false, {}, 0, false, "lite sync server URL is not usable"};
|
|
}
|
|
return LiteServerSelectionResult{
|
|
true,
|
|
LiteServerEndpoint{overrideUrl, "Request", true},
|
|
0,
|
|
true,
|
|
{}
|
|
};
|
|
}
|
|
return selectLiteServer(connectionSettings_);
|
|
}
|
|
|
|
LiteSyncPlan LiteSyncService::makePlan(LiteSyncOperation operation,
|
|
const std::string& serverUrl,
|
|
bool bridgeExecutionAllowed,
|
|
LiteSyncStartMode startMode,
|
|
bool forceRescan,
|
|
bool afterRestore) const
|
|
{
|
|
LiteSyncPlan plan;
|
|
plan.operation = operation;
|
|
plan.startMode = startMode;
|
|
plan.bridgeExecutionAllowed = bridgeExecutionAllowed;
|
|
plan.forceRescan = forceRescan;
|
|
plan.afterRestore = afterRestore;
|
|
plan.command = operation == LiteSyncOperation::StartSync ? "sync" : "syncstatus";
|
|
|
|
auto selection = selectServerForRequest(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;
|
|
}
|
|
|
|
WalletBackendStatus LiteSyncService::statusFor(LiteSyncAvailability availability,
|
|
const std::string& detail) const
|
|
{
|
|
switch (availability) {
|
|
case LiteSyncAvailability::Ready:
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Disconnected,
|
|
detail.empty() ? "lite sync scaffold ready; sync is not started" : detail);
|
|
case LiteSyncAvailability::UnsupportedBuild:
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Unavailable,
|
|
"lite sync service is unsupported in full-node builds");
|
|
case LiteSyncAvailability::BackendUnavailable:
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Unavailable,
|
|
"lite backend is not linked");
|
|
case LiteSyncAvailability::BridgeUnavailable:
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Unavailable,
|
|
detail.empty() ? (bridge_ ? bridge_->unavailableReason() : "lite bridge is unavailable") : detail);
|
|
case LiteSyncAvailability::BridgeCallsDisabled:
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Unavailable,
|
|
"lite syncstatus bridge calls are disabled");
|
|
case LiteSyncAvailability::NoUsableServer:
|
|
return makeSyncScaffoldStatus(
|
|
WalletBackendState::Error,
|
|
detail.empty() ? "no usable lite servers are configured" : detail);
|
|
}
|
|
|
|
return makeSyncScaffoldStatus(WalletBackendState::Unavailable, "unknown lite sync state");
|
|
}
|
|
|
|
LiteSyncStartResult LiteSyncService::blockedStartResult(const LiteSyncPlan& plan,
|
|
const WalletBackendStatus& blockedStatus) const
|
|
{
|
|
LiteSyncStartResult result;
|
|
result.plan = plan;
|
|
result.status = blockedStatus;
|
|
result.error = blockedStatus.message;
|
|
return result;
|
|
}
|
|
|
|
LiteSyncStatusResult LiteSyncService::blockedStatusResult(const LiteSyncPlan& plan,
|
|
const WalletBackendStatus& blockedStatus) const
|
|
{
|
|
LiteSyncStatusResult result;
|
|
result.plan = plan;
|
|
result.status = blockedStatus;
|
|
result.error = blockedStatus.message;
|
|
return result;
|
|
}
|
|
|
|
} // namespace dragonx::wallet
|