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

@@ -71,7 +71,8 @@ Guard full-node-only code paths with `#if DRAGONX_LITE_BUILD` / chat code with `
The Lite variant is **functionally complete and runtime-verified on Linux + Windows** (work lives on branch `cleanup/lite-plan-churn`, **local-only — not pushed yet**): The Lite variant is **functionally complete and runtime-verified on Linux + Windows** (work lives on branch `cleanup/lite-plan-churn`, **local-only — not pushed yet**):
- **Implemented:** lifecycle (create/open/restore + auto-open on startup), sync, refresh, send / shield / import / export / seed, persistence (the backend does *not* auto-save after sync/send/shield — the controller triggers `save` at those points), and passphrase **encryption** (encrypt/unlock/lock/decrypt + Settings UI + send-time & startup unlock; the backend locks immediately on `encrypt`). All controller-tested against the fake backend (`tests/fake_lite_backend.h`) and smoke-verified against the real SDXL backend via `tools/lite_smoke` (incl. a full sync). GUI is wired end-to-end with lite-appropriate wording; the full-node RPC connect loop / wizard / daemon strings are gated out of lite (lite "online" is derived from `lite_wallet_->walletOpen()`, not RPC). - **Implemented:** lifecycle (create/open/restore + auto-open on startup), sync, refresh, send / shield / import / export / seed, persistence (the backend does *not* auto-save after sync/send/shield — the controller triggers `save` at those points), and passphrase **encryption** (encrypt/unlock/lock/decrypt + Settings UI + send-time & startup unlock; the backend locks immediately on `encrypt`). All controller-tested against the fake backend (`tests/fake_lite_backend.h`) and smoke-verified against the real SDXL backend via `tools/lite_smoke` (incl. a full sync). GUI is wired end-to-end with lite-appropriate wording; the full-node RPC connect loop / wizard / daemon strings are gated out of lite (lite "online" is derived from `lite_wallet_->walletOpen()`, not RPC).
- **Packaging (verified):** `./build.sh --lite-backend --linux-release` (zip + AppImage) and `--win-release` (cross-compiled `.exe`; first build the Windows backend artifact with `scripts/build-lite-backend-artifact.sh --platform windows`). Both correctly exclude full-node assets. - **Packaging (verified):** `./build.sh --lite-backend --linux-release` (zip + AppImage) and `--win-release` (cross-compiled `.exe`; first build the Windows backend artifact with `scripts/build-lite-backend-artifact.sh --platform windows`). Both correctly exclude full-node assets.
- **Remaining (M5b):** macOS packaging, CI backend-artifact build + signing, runtime kill-switch / staged rollout. - **Rollout / kill-switch (implemented):** `wallet/lite_rollout_policy.{h,cpp}` is a pure, fail-open gate (local-only, no network) feeding `LiteWalletLifecycleService::availability()` (new `RolloutDisabled` reason). Inputs: the emergency env var `DRAGONX_LITE_KILL_SWITCH` (absolute — not even `force_on` bypasses it); a `lite_rollout` setting (`auto`/`force_on`/`force_off`); and an optional **locally-cached** manifest at `<config-dir>/lite_rollout.json` (`global_enabled`, `min_version`/`max_version`, `blocked_versions`, `rollout_permille`, `message`) keyed for staged rollout on a hashed, never-transmitted per-install id. A signed remote fetcher can populate that cache later without touching the policy. Resolved in `App::rebuildLiteWallet()`; the disable message surfaces via the lifecycle status. Unit-tested + runtime-verified (env / manifest / control).
- **Remaining (M5b):** macOS packaging, CI backend-artifact build + signing.
- **To publish:** rename branch → `feat/lite-wallet`, base the PR on `dev` (the full-node UX is already there), and handle the dormant gated-OFF HushChat content bundled in commit `af06b8b`. - **To publish:** rename branch → `feat/lite-wallet`, base the PR on `dev` (the full-node UX is already there), and handle the dormant gated-OFF HushChat content bundled in commit `af06b8b`.
The detailed milestone plan and design history (the v2 plan, backend artifact/ABI/signing design docs, the v1 plan, chat specs, etc.) are kept **untracked** under `docs/_archive/`. The detailed milestone plan and design history (the v2 plan, backend artifact/ABI/signing design docs, the v1 plan, chat specs, etc.) are kept **untracked** under `docs/_archive/`.

View File

@@ -390,6 +390,7 @@ set(APP_SOURCES
src/services/wallet_security_workflow_executor.cpp src/services/wallet_security_workflow_executor.cpp
src/chat/chat_protocol.cpp src/chat/chat_protocol.cpp
src/wallet/lite_owned_string.cpp src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_client_bridge.cpp src/wallet/lite_client_bridge.cpp
src/wallet/lite_connection_service.cpp src/wallet/lite_connection_service.cpp
src/wallet/lite_wallet_controller.cpp src/wallet/lite_wallet_controller.cpp
@@ -504,6 +505,7 @@ set(APP_HEADERS
src/wallet/wallet_capabilities.h src/wallet/wallet_capabilities.h
src/wallet/wallet_backend.h src/wallet/wallet_backend.h
src/wallet/lite_owned_string.h src/wallet/lite_owned_string.h
src/wallet/lite_rollout_policy.h
src/wallet/lite_client_bridge.h src/wallet/lite_client_bridge.h
src/wallet/lite_connection_service.h src/wallet/lite_connection_service.h
src/wallet/lite_result_parsers.h src/wallet/lite_result_parsers.h
@@ -689,6 +691,7 @@ if(DRAGONX_LITE_BACKEND_READY)
tools/lite_smoke.cpp tools/lite_smoke.cpp
src/wallet/lite_client_bridge.cpp src/wallet/lite_client_bridge.cpp
src/wallet/lite_owned_string.cpp src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_connection_service.cpp src/wallet/lite_connection_service.cpp
src/wallet/lite_result_parsers.cpp src/wallet/lite_result_parsers.cpp
) )
@@ -934,6 +937,7 @@ if(BUILD_TESTING)
src/services/wallet_security_workflow_executor.cpp src/services/wallet_security_workflow_executor.cpp
src/chat/chat_protocol.cpp src/chat/chat_protocol.cpp
src/wallet/lite_owned_string.cpp src/wallet/lite_owned_string.cpp
src/wallet/lite_rollout_policy.cpp
src/wallet/lite_client_bridge.cpp src/wallet/lite_client_bridge.cpp
src/wallet/lite_connection_service.cpp src/wallet/lite_connection_service.cpp
src/wallet/lite_wallet_controller.cpp src/wallet/lite_wallet_controller.cpp

View File

@@ -13,7 +13,9 @@
#include "config/settings.h" #include "config/settings.h"
#include "wallet/lite_wallet_controller.h" #include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_server_selection_adapter.h" #include "wallet/lite_wallet_server_selection_adapter.h"
#include "wallet/lite_rollout_policy.h"
#include <cstdlib> // std::getenv for the lite kill-switch env var
#include <sodium.h> // sodium_memzero for the lite unlock-passphrase buffer #include <sodium.h> // sodium_memzero for the lite unlock-passphrase buffer
#include "daemon/daemon_controller.h" #include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h" #include "daemon/embedded_daemon.h"
@@ -370,6 +372,62 @@ void App::preFrame()
} }
} }
namespace {
// Resolve the lite-wallet rollout / kill-switch decision (see wallet/lite_rollout_policy.h).
// Local-only: reads the override + a stable per-install id from settings, an emergency env var,
// and a locally-cached manifest file (NO network fetch). Fail-open: a missing/invalid manifest
// leaves the wallet enabled.
wallet::LiteRolloutDecision resolveLiteRolloutDecision(config::Settings& settings)
{
using namespace dragonx::wallet;
LiteRolloutInputs inputs;
inputs.appVersion = DRAGONX_VERSION;
// Emergency kill-switch env var: any value other than empty/"0"/"false" disables.
if (const char* env = std::getenv("DRAGONX_LITE_KILL_SWITCH")) {
const std::string v = env;
inputs.killSwitchEnv = !v.empty() && v != "0" && v != "false";
}
inputs.override = liteRolloutOverrideFromString(settings.getLiteRolloutOverride());
// Stable per-install bucket source — generated once, persisted, never transmitted, no PII.
std::string installId = settings.getLiteInstallId();
if (installId.empty()) {
if (sodium_init() >= 0) {
unsigned char buf[16];
randombytes_buf(buf, sizeof(buf));
static const char kHex[] = "0123456789abcdef";
installId.reserve(sizeof(buf) * 2);
for (unsigned char c : buf) {
installId.push_back(kHex[c >> 4]);
installId.push_back(kHex[c & 0x0F]);
}
}
settings.setLiteInstallId(installId);
settings.save();
}
inputs.installBucket = liteRolloutBucketFromInstallId(installId);
// Local manifest cache next to settings.json — no network fetch (a signed remote fetcher can
// populate this later). Absent/unreadable -> fail-open.
try {
const std::filesystem::path cfgDir =
std::filesystem::path(config::Settings::getDefaultPath()).parent_path();
inputs.manifest = loadLiteRolloutManifestFromFile((cfgDir / "lite_rollout.json").string());
} catch (...) {
// leave manifest absent -> fail-open
}
const LiteRolloutDecision decision = evaluateLiteRollout(inputs);
if (!decision.allowed) {
DEBUG_LOGF("[lite-rollout] lite wallet gated OFF: %s — %s\n",
liteRolloutStatusName(decision.status), decision.message.c_str());
}
return decision;
}
} // namespace
void App::rebuildLiteWallet() void App::rebuildLiteWallet()
{ {
if (!supportsLiteBackend() || !settings_) return; if (!supportsLiteBackend() || !settings_) return;
@@ -381,7 +439,8 @@ void App::rebuildLiteWallet()
lite_wallet_ = wallet::LiteWalletController::createLinked( lite_wallet_ = wallet::LiteWalletController::createLinked(
walletCapabilities(), walletCapabilities(),
wallet::liteConnectionSettingsFromAppSettings(*settings_)); wallet::liteConnectionSettingsFromAppSettings(*settings_),
resolveLiteRolloutDecision(*settings_));
lite_wallet_->setPersistCallback([this]() { settings_->save(); }); lite_wallet_->setPersistCallback([this]() { settings_->save(); });
} }

View File

@@ -222,6 +222,13 @@ bool Settings::load(const std::string& path)
lite_servers_.push_back(preference); lite_servers_.push_back(preference);
} }
} }
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
const auto v = lite["rollout_override"].get<std::string>();
lite_rollout_override_ = (v == "force_on" || v == "force_off") ? v : "auto";
}
if (lite.contains("install_id") && lite["install_id"].is_string()) {
lite_install_id_ = lite["install_id"].get<std::string>();
}
} }
if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get<bool>(); if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get<bool>();
if (j.contains("debug_categories") && j["debug_categories"].is_array()) { if (j.contains("debug_categories") && j["debug_categories"].is_array()) {
@@ -355,6 +362,8 @@ bool Settings::save(const std::string& path)
entry["enabled"] = server.enabled; entry["enabled"] = server.enabled;
lite["servers"].push_back(entry); lite["servers"].push_back(entry);
} }
lite["rollout_override"] = lite_rollout_override_;
lite["install_id"] = lite_install_id_;
j["lite_wallet"] = lite; j["lite_wallet"] = lite;
} }
j["verbose_logging"] = verbose_logging_; j["verbose_logging"] = verbose_logging_;

View File

@@ -244,6 +244,15 @@ public:
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; } const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; } void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
// Lite wallet rollout / kill-switch (see wallet/lite_rollout_policy.h).
// Override: "auto" (honor rollout manifest), "force_on", or "force_off".
std::string getLiteRolloutOverride() const { return lite_rollout_override_; }
void setLiteRolloutOverride(const std::string& v) { lite_rollout_override_ = v; }
// Stable, locally-generated install id used only to derive the staged-rollout bucket.
// Never transmitted; carries no PII. Generated on first use if empty.
std::string getLiteInstallId() const { return lite_install_id_; }
void setLiteInstallId(const std::string& v) { lite_install_id_ = v; }
// Verbose diagnostic logging (connection attempts, daemon state, port owner, etc.) // Verbose diagnostic logging (connection attempts, daemon state, port owner, etc.)
bool getVerboseLogging() const { return verbose_logging_; } bool getVerboseLogging() const { return verbose_logging_; }
void setVerboseLogging(bool v) { verbose_logging_ = v; } void setVerboseLogging(bool v) { verbose_logging_ = v; }
@@ -392,6 +401,8 @@ private:
std::string lite_chain_name_ = "main"; // SDXL backend chain id; must be main/test/regtest std::string lite_chain_name_ = "main"; // SDXL backend chain id; must be main/test/regtest
std::size_t lite_random_selection_seed_ = 0; std::size_t lite_random_selection_seed_ = 0;
bool lite_persist_selected_server_ = true; bool lite_persist_selected_server_ = true;
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
std::string lite_install_id_; // random local-only id; rollout-bucket source
std::vector<LiteServerPreference> lite_servers_ = { std::vector<LiteServerPreference> lite_servers_ = {
{"https://lite.dragonx.is", "DragonX Lite", true}, {"https://lite.dragonx.is", "DragonX Lite", true},
{"https://lite1.dragonx.is", "DragonX Lite 1", true}, {"https://lite1.dragonx.is", "DragonX Lite 1", true},

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

View File

@@ -20,6 +20,7 @@
#include "lite_connection_service.h" #include "lite_connection_service.h"
#include "lite_wallet_lifecycle_service.h" #include "lite_wallet_lifecycle_service.h"
#include "lite_wallet_gateway.h" #include "lite_wallet_gateway.h"
#include "lite_rollout_policy.h"
#include "lite_sync_service.h" #include "lite_sync_service.h"
#include "lite_wallet_state_mapper.h" #include "lite_wallet_state_mapper.h"
#include "wallet_backend.h" #include "wallet_backend.h"
@@ -53,6 +54,8 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
struct LiteWalletControllerOptions { struct LiteWalletControllerOptions {
bool allowBridgeCalls = true; bool allowBridgeCalls = true;
bool rolloutBlocked = false; // runtime kill-switch / staged-rollout gate is blocking
std::string rolloutMessage; // user-facing reason when rolloutBlocked
}; };
struct LiteNewAddressResult { struct LiteNewAddressResult {
@@ -133,10 +136,13 @@ public:
LiteWalletController(const LiteWalletController&) = delete; LiteWalletController(const LiteWalletController&) = delete;
LiteWalletController& operator=(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( static std::unique_ptr<LiteWalletController> createLinked(
WalletCapabilities capabilities, WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings); LiteConnectionSettings connectionSettings,
LiteRolloutDecision rollout = LiteRolloutDecision{});
// Invoked after a wallet becomes ready, so the owner can persist settings. // Invoked after a wallet becomes ready, so the owner can persist settings.
void setPersistCallback(std::function<void()> callback) { persist_ = std::move(callback); } void setPersistCallback(std::function<void()> callback) { persist_ = std::move(callback); }

View File

@@ -91,6 +91,8 @@ const char* liteWalletLifecycleAvailabilityName(LiteWalletLifecycleAvailability
return "BridgeCallsDisabled"; return "BridgeCallsDisabled";
case LiteWalletLifecycleAvailability::NoUsableServer: case LiteWalletLifecycleAvailability::NoUsableServer:
return "NoUsableServer"; return "NoUsableServer";
case LiteWalletLifecycleAvailability::RolloutDisabled:
return "RolloutDisabled";
} }
return "Unknown"; return "Unknown";
} }
@@ -131,6 +133,9 @@ LiteWalletLifecycleAvailability LiteWalletLifecycleService::availability() const
if (!isLiteBuild(capabilities_)) return LiteWalletLifecycleAvailability::UnsupportedBuild; if (!isLiteBuild(capabilities_)) return LiteWalletLifecycleAvailability::UnsupportedBuild;
if (!supportsLiteBackend(capabilities_)) return LiteWalletLifecycleAvailability::BackendUnavailable; if (!supportsLiteBackend(capabilities_)) return LiteWalletLifecycleAvailability::BackendUnavailable;
if (!bridge_ || !bridge_->available()) return LiteWalletLifecycleAvailability::BridgeUnavailable; 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 (!selectLiteServer(connectionSettings_).ok) return LiteWalletLifecycleAvailability::NoUsableServer;
if (!options_.allowBridgeCalls) return LiteWalletLifecycleAvailability::BridgeCallsDisabled; if (!options_.allowBridgeCalls) return LiteWalletLifecycleAvailability::BridgeCallsDisabled;
return LiteWalletLifecycleAvailability::Ready; return LiteWalletLifecycleAvailability::Ready;
@@ -142,6 +147,9 @@ WalletBackendStatus LiteWalletLifecycleService::status() const
if (currentAvailability == LiteWalletLifecycleAvailability::NoUsableServer) { if (currentAvailability == LiteWalletLifecycleAvailability::NoUsableServer) {
return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error); return statusFor(currentAvailability, selectLiteServer(connectionSettings_).error);
} }
if (currentAvailability == LiteWalletLifecycleAvailability::RolloutDisabled) {
return statusFor(currentAvailability, options_.rolloutMessage);
}
return statusFor(currentAvailability); return statusFor(currentAvailability);
} }
@@ -310,6 +318,14 @@ WalletBackendStatus LiteWalletLifecycleService::statusFor(
{}, {},
0.0 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}; return WalletBackendStatus{WalletBackendState::Unavailable, "unknown lite wallet lifecycle state", {}, {}, 0.0};

View File

@@ -28,7 +28,8 @@ enum class LiteWalletLifecycleAvailability {
BackendUnavailable, BackendUnavailable,
BridgeUnavailable, BridgeUnavailable,
BridgeCallsDisabled, BridgeCallsDisabled,
NoUsableServer NoUsableServer,
RolloutDisabled // runtime kill-switch / staged-rollout gate (see lite_rollout_policy.h)
}; };
enum class LitePrivateDataKind { enum class LitePrivateDataKind {
@@ -93,6 +94,8 @@ struct LiteWalletLifecycleResult {
struct LiteWalletLifecycleOptions { struct LiteWalletLifecycleOptions {
bool allowBridgeCalls = false; 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); const char* liteWalletLifecycleOperationName(LiteWalletLifecycleOperation operation);

View File

@@ -21,6 +21,7 @@
#include "util/amount_format.h" #include "util/amount_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "wallet/lite_owned_string.h" #include "wallet/lite_owned_string.h"
#include "wallet/lite_rollout_policy.h"
#include "wallet/lite_wallet_controller.h" #include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_gateway.h" #include "wallet/lite_wallet_gateway.h"
#include "wallet/lite_wallet_state_mapper.h" #include "wallet/lite_wallet_state_mapper.h"
@@ -3767,6 +3768,197 @@ void testLiteWalletControllerShutdownDoesNotHangDuringSync()
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
} }
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
void testLiteRolloutVersionCompare()
{
using namespace dragonx::wallet;
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0); // missing components treated as 0
EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0); // pre-release suffix ignored
EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1); // numeric, not lexical
}
void testLiteRolloutBucketAndOverrideHelpers()
{
using namespace dragonx::wallet;
const int a = liteRolloutBucketFromInstallId("install-abc");
EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc")); // deterministic
EXPECT_TRUE(a >= 0 && a < 1000); // in range
EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0); // empty -> 0
EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
!= liteRolloutBucketFromInstallId("install-xyz"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
std::string("force_on"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
std::string("force_off"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
std::string("auto"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
std::string("auto")); // unknown -> auto
}
void testLiteRolloutPolicyOverridesAndKillSwitch()
{
using namespace dragonx::wallet;
LiteRolloutInputs in;
in.appVersion = "1.2.0";
// No manifest, auto -> allowed (fail-open).
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
// force_off -> disabled by local override.
in.override = LiteRolloutOverride::ForceOff;
auto off = evaluateLiteRollout(in);
EXPECT_FALSE(off.allowed);
EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);
// force_on -> allowed, even with a disabling manifest.
in.override = LiteRolloutOverride::ForceOn;
in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
// env kill-switch is absolute: disables even with force_on.
in.killSwitchEnv = true;
auto killed = evaluateLiteRollout(in);
EXPECT_FALSE(killed.allowed);
EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
}
void testLiteRolloutPolicyManifestGates()
{
using namespace dragonx::wallet;
LiteRolloutInputs in;
in.appVersion = "1.2.0";
in.manifest.present = true;
in.manifest.valid = true;
// Global disable carries the manifest message through to the decision.
in.manifest.globalEnabled = false;
in.manifest.message = "paused for maintenance";
{
auto d = evaluateLiteRollout(in);
EXPECT_FALSE(d.allowed);
EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
EXPECT_EQ(d.message, std::string("paused for maintenance"));
}
in.manifest.globalEnabled = true;
in.manifest.message.clear();
// Version floor / ceiling / blocklist.
in.manifest.minVersion = "1.3.0";
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
in.manifest.minVersion.clear();
in.manifest.maxVersion = "1.1.0";
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
in.manifest.maxVersion.clear();
in.manifest.blockedVersions = {"1.2.0"};
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
in.manifest.blockedVersions.clear();
// Within range and not blocked -> allowed.
in.manifest.minVersion = "1.0.0";
in.manifest.maxVersion = "2.0.0";
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
void testLiteRolloutPolicyStagedRollout()
{
using namespace dragonx::wallet;
LiteRolloutInputs in;
in.appVersion = "1.2.0";
in.manifest.present = true;
in.manifest.valid = true;
in.manifest.globalEnabled = true;
in.manifest.rolloutPermille = 0; // nobody (bucket 0 >= 0)
in.installBucket = 0;
EXPECT_FALSE(evaluateLiteRollout(in).allowed);
in.manifest.rolloutPermille = 500; // bucket < permille is in
in.installBucket = 499;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
in.installBucket = 500;
auto out = evaluateLiteRollout(in);
EXPECT_FALSE(out.allowed);
EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);
in.manifest.rolloutPermille = 1000; // everyone (bucket 999 < 1000)
in.installBucket = 999;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
void testLiteRolloutManifestLoader()
{
using namespace dragonx::wallet;
// Missing file -> not present -> fail-open allowed.
auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json");
EXPECT_FALSE(missing.present);
{
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
// A valid manifest file parses into the expected fields.
const std::string path = "/tmp/obsidian-rollout-test.json";
{
std::ofstream f(path);
f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})";
}
auto m = loadLiteRolloutManifestFromFile(path);
EXPECT_TRUE(m.present);
EXPECT_TRUE(m.valid);
EXPECT_FALSE(m.globalEnabled);
EXPECT_EQ(m.minVersion, std::string("1.0.0"));
EXPECT_EQ(m.rolloutPermille, 250);
EXPECT_EQ(m.message, std::string("hold"));
std::remove(path.c_str());
// Malformed JSON -> present but invalid -> fail-open allowed.
const std::string badPath = "/tmp/obsidian-rollout-bad.json";
{ std::ofstream f(badPath); f << "{ this is not json"; }
auto bad = loadLiteRolloutManifestFromFile(badPath);
EXPECT_TRUE(bad.present);
EXPECT_FALSE(bad.valid);
{
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = bad;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
std::remove(badPath.c_str());
}
// M5b: the rollout gate threads through the controller -> lifecycle availability() and blocks
// lifecycle execution with the gate's user-facing message.
void testLiteWalletControllerRolloutGate()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
// Gated OFF: availability() reports RolloutDisabled, create is blocked, wallet stays closed.
{
LiteWalletController controller(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ true,
"lite paused for maintenance"});
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::RolloutDisabled);
const auto result = controller.createWallet(LiteWalletCreateRequest{});
EXPECT_FALSE(result.walletReady);
EXPECT_TRUE(result.status.message.find("maintenance") != std::string::npos);
EXPECT_FALSE(controller.walletOpen());
}
// Not gated: availability() is Ready (matches the other lifecycle tests).
{
LiteWalletController controller(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ false, ""});
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::Ready);
}
}
} // namespace } // namespace
int main() int main()
@@ -3813,6 +4005,13 @@ int main()
testLiteWalletGatewayRefreshSkipsFailedCommand(); testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel(); testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync(); testLiteWalletControllerShutdownDoesNotHangDuringSync();
testLiteRolloutVersionCompare();
testLiteRolloutBucketAndOverrideHelpers();
testLiteRolloutPolicyOverridesAndKillSwitch();
testLiteRolloutPolicyManifestGates();
testLiteRolloutPolicyStagedRollout();
testLiteRolloutManifestLoader();
testLiteWalletControllerRolloutGate();
testGeneratedResourceBehavior(); testGeneratedResourceBehavior();
if (g_failures != 0) { if (g_failures != 0) {