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:
@@ -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**):
|
||||
- **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.
|
||||
- **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`.
|
||||
|
||||
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/`.
|
||||
|
||||
@@ -390,6 +390,7 @@ set(APP_SOURCES
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_wallet_controller.cpp
|
||||
@@ -504,6 +505,7 @@ set(APP_HEADERS
|
||||
src/wallet/wallet_capabilities.h
|
||||
src/wallet/wallet_backend.h
|
||||
src/wallet/lite_owned_string.h
|
||||
src/wallet/lite_rollout_policy.h
|
||||
src/wallet/lite_client_bridge.h
|
||||
src/wallet/lite_connection_service.h
|
||||
src/wallet/lite_result_parsers.h
|
||||
@@ -689,6 +691,7 @@ if(DRAGONX_LITE_BACKEND_READY)
|
||||
tools/lite_smoke.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_result_parsers.cpp
|
||||
)
|
||||
@@ -934,6 +937,7 @@ if(BUILD_TESTING)
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_wallet_controller.cpp
|
||||
|
||||
61
src/app.cpp
61
src/app.cpp
@@ -13,7 +13,9 @@
|
||||
#include "config/settings.h"
|
||||
#include "wallet/lite_wallet_controller.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 "daemon/daemon_controller.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()
|
||||
{
|
||||
if (!supportsLiteBackend() || !settings_) return;
|
||||
@@ -381,7 +439,8 @@ void App::rebuildLiteWallet()
|
||||
|
||||
lite_wallet_ = wallet::LiteWalletController::createLinked(
|
||||
walletCapabilities(),
|
||||
wallet::liteConnectionSettingsFromAppSettings(*settings_));
|
||||
wallet::liteConnectionSettingsFromAppSettings(*settings_),
|
||||
resolveLiteRolloutDecision(*settings_));
|
||||
lite_wallet_->setPersistCallback([this]() { settings_->save(); });
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,13 @@ bool Settings::load(const std::string& path)
|
||||
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("debug_categories") && j["debug_categories"].is_array()) {
|
||||
@@ -355,6 +362,8 @@ bool Settings::save(const std::string& path)
|
||||
entry["enabled"] = server.enabled;
|
||||
lite["servers"].push_back(entry);
|
||||
}
|
||||
lite["rollout_override"] = lite_rollout_override_;
|
||||
lite["install_id"] = lite_install_id_;
|
||||
j["lite_wallet"] = lite;
|
||||
}
|
||||
j["verbose_logging"] = verbose_logging_;
|
||||
|
||||
@@ -244,6 +244,15 @@ public:
|
||||
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_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.)
|
||||
bool getVerboseLogging() const { return verbose_logging_; }
|
||||
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::size_t lite_random_selection_seed_ = 0;
|
||||
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_ = {
|
||||
{"https://lite.dragonx.is", "DragonX Lite", true},
|
||||
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
||||
|
||||
181
src/wallet/lite_rollout_policy.cpp
Normal file
181
src/wallet/lite_rollout_policy.cpp
Normal 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
|
||||
85
src/wallet/lite_rollout_policy.h
Normal file
85
src/wallet/lite_rollout_policy.h
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "util/amount_format.h"
|
||||
#include "util/payment_uri.h"
|
||||
#include "wallet/lite_owned_string.h"
|
||||
#include "wallet/lite_rollout_policy.h"
|
||||
#include "wallet/lite_wallet_controller.h"
|
||||
#include "wallet/lite_wallet_gateway.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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
int main()
|
||||
@@ -3813,6 +4005,13 @@ int main()
|
||||
testLiteWalletGatewayRefreshSkipsFailedCommand();
|
||||
testLiteWalletControllerWorkerProducesModel();
|
||||
testLiteWalletControllerShutdownDoesNotHangDuringSync();
|
||||
testLiteRolloutVersionCompare();
|
||||
testLiteRolloutBucketAndOverrideHelpers();
|
||||
testLiteRolloutPolicyOverridesAndKillSwitch();
|
||||
testLiteRolloutPolicyManifestGates();
|
||||
testLiteRolloutPolicyStagedRollout();
|
||||
testLiteRolloutManifestLoader();
|
||||
testLiteWalletControllerRolloutGate();
|
||||
testGeneratedResourceBehavior();
|
||||
|
||||
if (g_failures != 0) {
|
||||
|
||||
Reference in New Issue
Block a user