feat(lite): runtime kill-switch + staged-rollout gate (M5b)

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>
This commit is contained in:
2026-06-06 12:01:08 -05:00
parent ca14aaddc7
commit b3c2282b53
12 changed files with 584 additions and 8 deletions

View File

@@ -0,0 +1,181 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "lite_rollout_policy.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <sstream>
namespace dragonx {
namespace wallet {
namespace {
using json = nlohmann::json;
// Numeric dotted core of a version, dropping any "-prerelease" / "+build" suffix.
std::vector<long long> parseVersionCore(const std::string& v)
{
const std::string core = v.substr(0, v.find_first_of("-+"));
std::vector<long long> parts;
std::stringstream ss(core);
std::string token;
while (std::getline(ss, token, '.')) {
if (token.empty()) { parts.push_back(0); continue; }
try { parts.push_back(std::stoll(token)); }
catch (...) { parts.push_back(0); }
}
return parts;
}
const char* defaultMessageFor(LiteRolloutStatus s)
{
switch (s) {
case LiteRolloutStatus::DisabledByKillSwitchEnv:
return "The lite wallet is disabled by an emergency kill-switch on this machine.";
case LiteRolloutStatus::DisabledByLocalOverride:
return "The lite wallet is turned off in this app's settings.";
case LiteRolloutStatus::DisabledByManifest:
return "The lite wallet is currently disabled by the rollout policy.";
case LiteRolloutStatus::DisabledUnsupportedVersion:
return "This app version is not supported by the lite wallet; please update.";
case LiteRolloutStatus::DisabledBlockedVersion:
return "This app version has a known issue and the lite wallet is disabled; please update.";
case LiteRolloutStatus::DisabledByStagedRollout:
return "The lite wallet is rolling out gradually and is not yet enabled for this install.";
case LiteRolloutStatus::Allowed:
default:
return "";
}
}
LiteRolloutDecision disabledDecision(LiteRolloutStatus s, const std::string& manifestMessage)
{
LiteRolloutDecision d;
d.allowed = false;
d.status = s;
d.message = !manifestMessage.empty() ? manifestMessage : defaultMessageFor(s);
return d;
}
} // namespace
int compareLiteVersions(const std::string& a, const std::string& b)
{
const auto pa = parseVersionCore(a);
const auto pb = parseVersionCore(b);
const std::size_t n = std::max(pa.size(), pb.size());
for (std::size_t i = 0; i < n; ++i) {
const long long va = i < pa.size() ? pa[i] : 0;
const long long vb = i < pb.size() ? pb[i] : 0;
if (va < vb) return -1;
if (va > vb) return 1;
}
return 0;
}
int liteRolloutBucketFromInstallId(const std::string& installId)
{
if (installId.empty()) return 0;
// FNV-1a (64-bit) → 0..999. Stable across runs for a given id.
std::uint64_t h = 1469598103934665603ULL;
for (unsigned char c : installId) {
h ^= static_cast<std::uint64_t>(c);
h *= 1099511628211ULL;
}
return static_cast<int>(h % 1000ULL);
}
LiteRolloutOverride liteRolloutOverrideFromString(const std::string& value)
{
if (value == "force_on") return LiteRolloutOverride::ForceOn;
if (value == "force_off") return LiteRolloutOverride::ForceOff;
return LiteRolloutOverride::Auto;
}
const char* liteRolloutOverrideToString(LiteRolloutOverride o)
{
switch (o) {
case LiteRolloutOverride::ForceOn: return "force_on";
case LiteRolloutOverride::ForceOff: return "force_off";
case LiteRolloutOverride::Auto: default: return "auto";
}
}
const char* liteRolloutStatusName(LiteRolloutStatus s)
{
switch (s) {
case LiteRolloutStatus::Allowed: return "Allowed";
case LiteRolloutStatus::DisabledByKillSwitchEnv: return "DisabledByKillSwitchEnv";
case LiteRolloutStatus::DisabledByLocalOverride: return "DisabledByLocalOverride";
case LiteRolloutStatus::DisabledByManifest: return "DisabledByManifest";
case LiteRolloutStatus::DisabledUnsupportedVersion: return "DisabledUnsupportedVersion";
case LiteRolloutStatus::DisabledBlockedVersion: return "DisabledBlockedVersion";
case LiteRolloutStatus::DisabledByStagedRollout: return "DisabledByStagedRollout";
}
return "Unknown";
}
LiteRolloutDecision evaluateLiteRollout(const LiteRolloutInputs& in)
{
// 1. Emergency env kill-switch is absolute — not even ForceOn bypasses it.
if (in.killSwitchEnv)
return disabledDecision(LiteRolloutStatus::DisabledByKillSwitchEnv, {});
// 2/3. Local override.
if (in.override == LiteRolloutOverride::ForceOff)
return disabledDecision(LiteRolloutStatus::DisabledByLocalOverride, {});
if (in.override == LiteRolloutOverride::ForceOn)
return LiteRolloutDecision{true, LiteRolloutStatus::Allowed, {}};
// 4. Manifest gates — fail-open: only enforced when a manifest is present AND valid.
const LiteRolloutManifest& m = in.manifest;
if (m.present && m.valid) {
if (!m.globalEnabled)
return disabledDecision(LiteRolloutStatus::DisabledByManifest, m.message);
if (!m.minVersion.empty() && compareLiteVersions(in.appVersion, m.minVersion) < 0)
return disabledDecision(LiteRolloutStatus::DisabledUnsupportedVersion, m.message);
if (!m.maxVersion.empty() && compareLiteVersions(in.appVersion, m.maxVersion) > 0)
return disabledDecision(LiteRolloutStatus::DisabledUnsupportedVersion, m.message);
for (const auto& bad : m.blockedVersions)
if (compareLiteVersions(in.appVersion, bad) == 0)
return disabledDecision(LiteRolloutStatus::DisabledBlockedVersion, m.message);
if (in.installBucket >= m.rolloutPermille)
return disabledDecision(LiteRolloutStatus::DisabledByStagedRollout, m.message);
}
// 5. Fail-open default.
return LiteRolloutDecision{true, LiteRolloutStatus::Allowed, {}};
}
LiteRolloutManifest loadLiteRolloutManifestFromFile(const std::string& path)
{
LiteRolloutManifest m; // present=false, valid=false (fail-open)
std::ifstream in(path);
if (!in.is_open()) return m; // no file -> no manifest
m.present = true;
try {
json j;
in >> j;
if (j.contains("global_enabled")) m.globalEnabled = j["global_enabled"].get<bool>();
if (j.contains("min_version")) m.minVersion = j["min_version"].get<std::string>();
if (j.contains("max_version")) m.maxVersion = j["max_version"].get<std::string>();
if (j.contains("blocked_versions"))
m.blockedVersions = j["blocked_versions"].get<std::vector<std::string>>();
if (j.contains("rollout_permille")) {
const int p = j["rollout_permille"].get<int>();
m.rolloutPermille = std::max(0, std::min(1000, p));
}
if (j.contains("message")) m.message = j["message"].get<std::string>();
m.valid = true;
} catch (...) {
m.valid = false; // parse error -> fail-open
}
return m;
}
} // namespace wallet
} // namespace dragonx

View File

@@ -0,0 +1,85 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Lite-wallet rollout / kill-switch policy.
//
// A pure decision core that gates whether the lite wallet may run, given a local override, an
// emergency env kill-switch, an optional rollout manifest, the running app version, and a stable
// per-install rollout bucket. Privacy posture (this is a privacy coin): the manifest is read from a
// LOCAL cache file only — there is NO runtime network fetch (a signed remote fetcher can populate
// that cache later without touching this policy), it is consulted FAIL-OPEN (absent/invalid →
// allowed, so a missing file never bricks a working install), and the install bucket is derived
// from a locally-generated random id that is never transmitted and carries no PII.
#pragma once
#include <string>
#include <vector>
namespace dragonx::wallet {
// User/support local override (persisted in settings as "lite_rollout").
enum class LiteRolloutOverride {
Auto, // honor the manifest / staged rollout
ForceOn, // always enable; bypasses the manifest + staged rollout, but NOT the env kill-switch
ForceOff // always disable
};
enum class LiteRolloutStatus {
Allowed,
DisabledByKillSwitchEnv, // DRAGONX_LITE_KILL_SWITCH=1 (emergency, absolute)
DisabledByLocalOverride, // lite_rollout=force_off
DisabledByManifest, // manifest global_enabled=false
DisabledUnsupportedVersion, // version below min / above max supported
DisabledBlockedVersion, // version explicitly blocked
DisabledByStagedRollout // install bucket above the rollout threshold
};
// Rollout manifest. Loaded from a local cache file; present=false means no manifest (fail-open).
struct LiteRolloutManifest {
bool present = false; // a manifest file was found
bool valid = false; // it parsed and validated
bool globalEnabled = true; // master enable switch
std::string minVersion; // inclusive floor (empty = none)
std::string maxVersion; // inclusive ceiling (empty = none)
std::vector<std::string> blockedVersions;
int rolloutPermille = 1000; // 0..1000; install allowed when bucket < permille (1000 = 100%)
std::string message; // user-facing reason shown when disabled
};
struct LiteRolloutInputs {
LiteRolloutOverride override = LiteRolloutOverride::Auto;
bool killSwitchEnv = false;
LiteRolloutManifest manifest;
std::string appVersion;
int installBucket = 0; // 0..999, stable per install
};
struct LiteRolloutDecision {
bool allowed = true;
LiteRolloutStatus status = LiteRolloutStatus::Allowed;
std::string message; // user-facing (empty when allowed)
};
// The pure decision. No I/O. Evaluation order: env kill-switch (absolute) → local override →
// manifest gates (global / version / blocked / staged rollout) → fail-open allow.
LiteRolloutDecision evaluateLiteRollout(const LiteRolloutInputs& inputs);
// Stable bucket 0..999 from an install id (FNV-1a). Deterministic; empty id -> 0.
int liteRolloutBucketFromInstallId(const std::string& installId);
// Compare dotted version cores, ignoring any "-suffix"/"+build". Returns -1 / 0 / 1.
int compareLiteVersions(const std::string& a, const std::string& b);
// Override <-> string ("auto"/"force_on"/"force_off"); unknown parses to Auto.
LiteRolloutOverride liteRolloutOverrideFromString(const std::string& value);
const char* liteRolloutOverrideToString(LiteRolloutOverride override);
const char* liteRolloutStatusName(LiteRolloutStatus status);
// Load + validate a manifest from a JSON file. Missing file -> {present=false}; parse error ->
// {present=true, valid=false}. Both are fail-open at evaluation time.
LiteRolloutManifest loadLiteRolloutManifestFromFile(const std::string& path);
} // namespace dragonx::wallet

View File

@@ -196,7 +196,8 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
chainName_(connectionSettings.chainName),
lifecycle_(capabilities, connectionSettings, bridge_.get(),
LiteWalletLifecycleOptions{options.allowBridgeCalls}),
LiteWalletLifecycleOptions{options.allowBridgeCalls,
options.rolloutBlocked, options.rolloutMessage}),
gateway_(capabilities, connectionSettings, bridge_.get(),
LiteWalletGatewayOptions{options.allowBridgeCalls}),
sync_(capabilities, connectionSettings, bridge_.get(),
@@ -226,13 +227,14 @@ LiteWalletController::~LiteWalletController()
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings)
LiteConnectionSettings connectionSettings,
LiteRolloutDecision rollout)
{
return std::make_unique<LiteWalletController>(
capabilities,
std::move(connectionSettings),
LiteClientBridge::linkedSdxl(),
LiteWalletControllerOptions{true});
LiteWalletControllerOptions{true, !rollout.allowed, rollout.message});
}
void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result)

View File

@@ -20,6 +20,7 @@
#include "lite_connection_service.h"
#include "lite_wallet_lifecycle_service.h"
#include "lite_wallet_gateway.h"
#include "lite_rollout_policy.h"
#include "lite_sync_service.h"
#include "lite_wallet_state_mapper.h"
#include "wallet_backend.h"
@@ -53,6 +54,8 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
struct LiteWalletControllerOptions {
bool allowBridgeCalls = true;
bool rolloutBlocked = false; // runtime kill-switch / staged-rollout gate is blocking
std::string rolloutMessage; // user-facing reason when rolloutBlocked
};
struct LiteNewAddressResult {
@@ -133,10 +136,13 @@ public:
LiteWalletController(const LiteWalletController&) = delete;
LiteWalletController& operator=(const LiteWalletController&) = delete;
// Production factory: links the SDXL backend compiled into this build.
// Production factory: links the SDXL backend compiled into this build. The optional rollout
// decision (runtime kill-switch / staged rollout) gates lifecycle execution: when not allowed,
// availability() reports RolloutDisabled and create/open/restore are blocked with its message.
static std::unique_ptr<LiteWalletController> createLinked(
WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings);
LiteConnectionSettings connectionSettings,
LiteRolloutDecision rollout = LiteRolloutDecision{});
// Invoked after a wallet becomes ready, so the owner can persist settings.
void setPersistCallback(std::function<void()> callback) { persist_ = std::move(callback); }

View File

@@ -91,6 +91,8 @@ const char* liteWalletLifecycleAvailabilityName(LiteWalletLifecycleAvailability
return "BridgeCallsDisabled";
case LiteWalletLifecycleAvailability::NoUsableServer:
return "NoUsableServer";
case LiteWalletLifecycleAvailability::RolloutDisabled:
return "RolloutDisabled";
}
return "Unknown";
}
@@ -131,6 +133,9 @@ 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;
@@ -142,6 +147,9 @@ WalletBackendStatus LiteWalletLifecycleService::status() const
if (currentAvailability == LiteWalletLifecycleAvailability::NoUsableServer) {
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
}
if (currentAvailability == LiteWalletLifecycleAvailability::RolloutDisabled) {
return statusFor(currentAvailability, options_.rolloutMessage);
}
return statusFor(currentAvailability);
}
@@ -310,6 +318,14 @@ WalletBackendStatus LiteWalletLifecycleService::statusFor(
{},
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};

View File

@@ -28,7 +28,8 @@ enum class LiteWalletLifecycleAvailability {
BackendUnavailable,
BridgeUnavailable,
BridgeCallsDisabled,
NoUsableServer
NoUsableServer,
RolloutDisabled // runtime kill-switch / staged-rollout gate (see lite_rollout_policy.h)
};
enum class LitePrivateDataKind {
@@ -93,6 +94,8 @@ struct LiteWalletLifecycleResult {
struct LiteWalletLifecycleOptions {
bool allowBridgeCalls = false;
bool rolloutBlocked = false; // runtime kill-switch / staged-rollout gate is blocking
std::string rolloutMessage; // user-facing reason when rolloutBlocked
};
const char* liteWalletLifecycleOperationName(LiteWalletLifecycleOperation operation);