Adds a fail-open, local-only gate that decides whether the lite wallet may run,
so a post-release issue can disable it and rollout can be staged — without any
phone-home (privacy posture: no runtime network fetch; the per-install rollout
bucket is a hashed, never-transmitted local id).
- wallet/lite_rollout_policy.{h,cpp}: a pure decision core. Order — emergency env
kill-switch (absolute) -> local override -> manifest gates (global enable /
version floor-ceiling / blocklist / staged-rollout permille) -> fail-open allow.
Plus a JSON manifest loader (missing/invalid -> fail-open) and FNV-1a bucketing.
- Threads the decision through LiteWalletController -> LiteWalletLifecycleService:
new availability() reason RolloutDisabled blocks create/open/restore and surfaces
the gate's user-facing message via the lifecycle status.
- App::rebuildLiteWallet() resolves it from: DRAGONX_LITE_KILL_SWITCH (env), the
lite_rollout setting (auto/force_on/force_off), and a locally-cached manifest at
<config-dir>/lite_rollout.json. install id generated once via libsodium.
- Settings: persist lite_rollout override + the install id.
A signed remote fetcher can populate the manifest cache later without touching the
policy. Unit-tested (version compare, bucketing, override/env precedence, manifest
gates, staged rollout, loader fail-open, controller integration) and runtime-verified
on Linux (env kill-switch, manifest disable, control sync). Both variants build;
full suite passes; hygiene clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
413 lines
14 KiB
C++
413 lines
14 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "lite_wallet_lifecycle_service.h"
|
|
|
|
#include <cctype>
|
|
#include <utility>
|
|
|
|
namespace dragonx {
|
|
namespace wallet {
|
|
|
|
namespace {
|
|
|
|
std::string trimLifecycleCopy(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);
|
|
}
|
|
|
|
LiteRedactedPrivateData redactedField(LitePrivateDataKind kind, const std::string& value)
|
|
{
|
|
return LiteRedactedPrivateData{kind, !value.empty(), redactLitePrivateDataValue(value)};
|
|
}
|
|
|
|
std::vector<LiteRedactedPrivateData> createPrivateData(const LiteWalletCreateRequest& request)
|
|
{
|
|
return {redactedField(LitePrivateDataKind::Passphrase, request.passphrase)};
|
|
}
|
|
|
|
std::vector<LiteRedactedPrivateData> openPrivateData(const LiteWalletOpenRequest& request)
|
|
{
|
|
return {
|
|
redactedField(LitePrivateDataKind::WalletPath, request.walletPath),
|
|
redactedField(LitePrivateDataKind::Passphrase, request.passphrase)
|
|
};
|
|
}
|
|
|
|
std::vector<LiteRedactedPrivateData> restorePrivateData(const LiteWalletRestoreRequest& request)
|
|
{
|
|
return {
|
|
redactedField(LitePrivateDataKind::SeedPhrase, request.seedPhrase),
|
|
redactedField(LitePrivateDataKind::WalletPath, request.walletPath),
|
|
redactedField(LitePrivateDataKind::Passphrase, request.passphrase)
|
|
};
|
|
}
|
|
|
|
WalletBackendStatus lifecycleCompletedStatus()
|
|
{
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Disconnected,
|
|
"lite wallet lifecycle bridge call completed; sync is not implemented",
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
}
|
|
|
|
} // namespace
|
|
|
|
const char* liteWalletLifecycleOperationName(LiteWalletLifecycleOperation operation)
|
|
{
|
|
switch (operation) {
|
|
case LiteWalletLifecycleOperation::CreateNew:
|
|
return "CreateNew";
|
|
case LiteWalletLifecycleOperation::OpenExisting:
|
|
return "OpenExisting";
|
|
case LiteWalletLifecycleOperation::RestoreFromSeed:
|
|
return "RestoreFromSeed";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
const char* liteWalletLifecycleAvailabilityName(LiteWalletLifecycleAvailability availability)
|
|
{
|
|
switch (availability) {
|
|
case LiteWalletLifecycleAvailability::Ready:
|
|
return "Ready";
|
|
case LiteWalletLifecycleAvailability::UnsupportedBuild:
|
|
return "UnsupportedBuild";
|
|
case LiteWalletLifecycleAvailability::BackendUnavailable:
|
|
return "BackendUnavailable";
|
|
case LiteWalletLifecycleAvailability::BridgeUnavailable:
|
|
return "BridgeUnavailable";
|
|
case LiteWalletLifecycleAvailability::BridgeCallsDisabled:
|
|
return "BridgeCallsDisabled";
|
|
case LiteWalletLifecycleAvailability::NoUsableServer:
|
|
return "NoUsableServer";
|
|
case LiteWalletLifecycleAvailability::RolloutDisabled:
|
|
return "RolloutDisabled";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
const char* litePrivateDataKindName(LitePrivateDataKind kind)
|
|
{
|
|
switch (kind) {
|
|
case LitePrivateDataKind::SeedPhrase:
|
|
return "SeedPhrase";
|
|
case LitePrivateDataKind::Passphrase:
|
|
return "Passphrase";
|
|
case LitePrivateDataKind::WalletPath:
|
|
return "WalletPath";
|
|
case LitePrivateDataKind::BridgeResponse:
|
|
return "BridgeResponse";
|
|
}
|
|
return "Unknown";
|
|
}
|
|
|
|
std::string redactLitePrivateDataValue(const std::string& value)
|
|
{
|
|
return value.empty() ? "<empty>" : "<redacted>";
|
|
}
|
|
|
|
LiteWalletLifecycleService::LiteWalletLifecycleService(WalletCapabilities capabilities,
|
|
LiteConnectionSettings connectionSettings,
|
|
LiteClientBridge* bridge,
|
|
LiteWalletLifecycleOptions options)
|
|
: capabilities_(capabilities),
|
|
connectionSettings_(std::move(connectionSettings)),
|
|
bridge_(bridge),
|
|
options_(options)
|
|
{
|
|
}
|
|
|
|
LiteWalletLifecycleAvailability LiteWalletLifecycleService::availability() const
|
|
{
|
|
if (!isLiteBuild(capabilities_)) return LiteWalletLifecycleAvailability::UnsupportedBuild;
|
|
if (!supportsLiteBackend(capabilities_)) return LiteWalletLifecycleAvailability::BackendUnavailable;
|
|
if (!bridge_ || !bridge_->available()) return LiteWalletLifecycleAvailability::BridgeUnavailable;
|
|
// Runtime kill-switch / staged-rollout gate: a structural readiness check, applied before
|
|
// server selection so a gated-off wallet reports the rollout reason rather than a server error.
|
|
if (options_.rolloutBlocked) return LiteWalletLifecycleAvailability::RolloutDisabled;
|
|
if (!selectLiteServer(connectionSettings_).ok) return LiteWalletLifecycleAvailability::NoUsableServer;
|
|
if (!options_.allowBridgeCalls) return LiteWalletLifecycleAvailability::BridgeCallsDisabled;
|
|
return LiteWalletLifecycleAvailability::Ready;
|
|
}
|
|
|
|
WalletBackendStatus LiteWalletLifecycleService::status() const
|
|
{
|
|
const auto currentAvailability = availability();
|
|
if (currentAvailability == LiteWalletLifecycleAvailability::NoUsableServer) {
|
|
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
|
|
}
|
|
if (currentAvailability == LiteWalletLifecycleAvailability::RolloutDisabled) {
|
|
return statusFor(currentAvailability, options_.rolloutMessage);
|
|
}
|
|
return statusFor(currentAvailability);
|
|
}
|
|
|
|
LiteWalletLifecyclePlan LiteWalletLifecycleService::planCreateWallet(
|
|
const LiteWalletCreateRequest& request) const
|
|
{
|
|
return makePlan(LiteWalletLifecycleOperation::CreateNew,
|
|
request.serverUrl,
|
|
createPrivateData(request));
|
|
}
|
|
|
|
LiteWalletLifecyclePlan LiteWalletLifecycleService::planOpenWallet(
|
|
const LiteWalletOpenRequest& request) const
|
|
{
|
|
return makePlan(LiteWalletLifecycleOperation::OpenExisting,
|
|
request.serverUrl,
|
|
openPrivateData(request));
|
|
}
|
|
|
|
LiteWalletLifecyclePlan LiteWalletLifecycleService::planRestoreWallet(
|
|
const LiteWalletRestoreRequest& request) const
|
|
{
|
|
const std::string validationError = trimLifecycleCopy(request.seedPhrase).empty()
|
|
? "restore seed phrase is required"
|
|
: std::string();
|
|
return makePlan(LiteWalletLifecycleOperation::RestoreFromSeed,
|
|
request.serverUrl,
|
|
restorePrivateData(request),
|
|
validationError);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::createWallet(
|
|
const LiteWalletCreateRequest& request)
|
|
{
|
|
const auto plan = planCreateWallet(request);
|
|
if (!plan.ok) return blockedResult(plan, WalletBackendStatus{WalletBackendState::Error, plan.error, {}, {}, 0.0});
|
|
|
|
const auto currentStatus = status();
|
|
if (availability() != LiteWalletLifecycleAvailability::Ready) return blockedResult(plan, currentStatus);
|
|
|
|
return executeCreate(request, plan);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::openWallet(
|
|
const LiteWalletOpenRequest& request)
|
|
{
|
|
const auto plan = planOpenWallet(request);
|
|
if (!plan.ok) return blockedResult(plan, WalletBackendStatus{WalletBackendState::Error, plan.error, {}, {}, 0.0});
|
|
|
|
const auto currentStatus = status();
|
|
if (availability() != LiteWalletLifecycleAvailability::Ready) return blockedResult(plan, currentStatus);
|
|
|
|
return executeOpen(request, plan);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::restoreWallet(
|
|
const LiteWalletRestoreRequest& request)
|
|
{
|
|
const auto plan = planRestoreWallet(request);
|
|
if (!plan.ok) return blockedResult(plan, WalletBackendStatus{WalletBackendState::Error, plan.error, {}, {}, 0.0});
|
|
|
|
const auto currentStatus = status();
|
|
if (availability() != LiteWalletLifecycleAvailability::Ready) return blockedResult(plan, currentStatus);
|
|
|
|
return executeRestore(request, plan);
|
|
}
|
|
|
|
LiteServerSelectionResult LiteWalletLifecycleService::selectServerForRequest(
|
|
const std::string& serverUrl) const
|
|
{
|
|
const std::string overrideUrl = trimLifecycleCopy(serverUrl);
|
|
if (!overrideUrl.empty()) {
|
|
if (!isLiteServerUrlUsable(overrideUrl)) {
|
|
return LiteServerSelectionResult{false, {}, 0, false, "lite lifecycle server URL is not usable"};
|
|
}
|
|
return LiteServerSelectionResult{
|
|
true,
|
|
LiteServerEndpoint{overrideUrl, "Request", true},
|
|
0,
|
|
true,
|
|
{}
|
|
};
|
|
}
|
|
return selectLiteServer(connectionSettings_);
|
|
}
|
|
|
|
LiteWalletLifecyclePlan LiteWalletLifecycleService::makePlan(
|
|
LiteWalletLifecycleOperation operation,
|
|
const std::string& serverUrl,
|
|
std::vector<LiteRedactedPrivateData> privateData,
|
|
const std::string& validationError) const
|
|
{
|
|
LiteWalletLifecyclePlan plan;
|
|
plan.operation = operation;
|
|
plan.privateData = std::move(privateData);
|
|
plan.bridgeExecutionAllowed = options_.allowBridgeCalls;
|
|
|
|
if (!validationError.empty()) {
|
|
plan.error = validationError;
|
|
return plan;
|
|
}
|
|
|
|
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 LiteWalletLifecycleService::statusFor(
|
|
LiteWalletLifecycleAvailability availability,
|
|
const std::string& detail) const
|
|
{
|
|
switch (availability) {
|
|
case LiteWalletLifecycleAvailability::Ready:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Disconnected,
|
|
detail.empty() ? "lite wallet lifecycle scaffold ready; sync is not implemented" : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteWalletLifecycleAvailability::UnsupportedBuild:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
"lite wallet lifecycle is unsupported in full-node builds",
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteWalletLifecycleAvailability::BackendUnavailable:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
"lite backend is not linked",
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteWalletLifecycleAvailability::BridgeUnavailable:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
detail.empty() ? (bridge_ ? bridge_->unavailableReason() : "lite bridge is unavailable") : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteWalletLifecycleAvailability::BridgeCallsDisabled:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
"lite wallet lifecycle bridge calls are disabled",
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteWalletLifecycleAvailability::NoUsableServer:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Error,
|
|
detail.empty() ? "no usable lite servers are configured" : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
case LiteWalletLifecycleAvailability::RolloutDisabled:
|
|
return WalletBackendStatus{
|
|
WalletBackendState::Unavailable,
|
|
detail.empty() ? "the lite wallet is disabled by the rollout policy" : detail,
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
}
|
|
|
|
return WalletBackendStatus{WalletBackendState::Unavailable, "unknown lite wallet lifecycle state", {}, {}, 0.0};
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::executeCreate(
|
|
const LiteWalletCreateRequest& request,
|
|
const LiteWalletLifecyclePlan& plan)
|
|
{
|
|
auto bridgeCall = bridge_->initializeNew(request.dangerous, plan.server.url);
|
|
return bridgeResult(plan, lifecycleCompletedStatus(), bridgeCall);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::executeOpen(
|
|
const LiteWalletOpenRequest& request,
|
|
const LiteWalletLifecyclePlan& plan)
|
|
{
|
|
auto bridgeCall = bridge_->initializeExisting(request.dangerous, plan.server.url);
|
|
return bridgeResult(plan, lifecycleCompletedStatus(), bridgeCall);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::executeRestore(
|
|
const LiteWalletRestoreRequest& request,
|
|
const LiteWalletLifecyclePlan& plan)
|
|
{
|
|
auto bridgeCall = bridge_->initializeNewFromPhrase(
|
|
request.dangerous,
|
|
plan.server.url,
|
|
request.seedPhrase,
|
|
request.birthday,
|
|
request.account,
|
|
request.overwrite);
|
|
return bridgeResult(plan, lifecycleCompletedStatus(), bridgeCall);
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::blockedResult(
|
|
const LiteWalletLifecyclePlan& plan,
|
|
const WalletBackendStatus& blockedStatus) const
|
|
{
|
|
LiteWalletLifecycleResult result;
|
|
result.operation = plan.operation;
|
|
result.plan = plan;
|
|
result.status = blockedStatus;
|
|
result.error = blockedStatus.message;
|
|
return result;
|
|
}
|
|
|
|
LiteWalletLifecycleResult LiteWalletLifecycleService::bridgeResult(
|
|
const LiteWalletLifecyclePlan& plan,
|
|
const WalletBackendStatus& successStatus,
|
|
const LiteBridgeStringResult& bridgeCall) const
|
|
{
|
|
LiteWalletLifecycleResult result;
|
|
result.operation = plan.operation;
|
|
result.plan = plan;
|
|
result.attempted = true;
|
|
result.bridgeResponseRedacted = redactLitePrivateDataValue(bridgeCall.ok ? bridgeCall.value : bridgeCall.error);
|
|
|
|
if (!bridgeCall.ok) {
|
|
result.status = WalletBackendStatus{
|
|
WalletBackendState::Error,
|
|
"lite wallet lifecycle bridge call failed",
|
|
{},
|
|
{},
|
|
0.0
|
|
};
|
|
result.error = result.status.message;
|
|
return result;
|
|
}
|
|
|
|
result.ok = true;
|
|
result.bridgeAccepted = true;
|
|
// The bridge already classifies "Error:"-prefixed responses (and null/empty returns) as
|
|
// failures (ok=false), so reaching here means the backend genuinely succeeded. The success
|
|
// payloads are NOT uniformly JSON: create/restore return a seed object
|
|
// ({"seed":..,"birthday":..}), but open (litelib_initialize_existing) returns the bare
|
|
// string "OK". A JSON-validity test would therefore wrongly mark a successful open as
|
|
// not-ready, so treat any non-empty success response as a ready wallet.
|
|
result.walletReady = !bridgeCall.value.empty();
|
|
result.status = successStatus;
|
|
return result;
|
|
}
|
|
|
|
} // namespace wallet
|
|
} // namespace dragonx
|