From b3c2282b53e786bb28b0f268fcbd3af403aabeea Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 6 Jun 2026 12:01:08 -0500 Subject: [PATCH] feat(lite): runtime kill-switch + staged-rollout gate (M5b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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 --- CLAUDE.md | 3 +- CMakeLists.txt | 4 + src/app.cpp | 61 +++++- src/config/settings.cpp | 9 + src/config/settings.h | 11 + src/wallet/lite_rollout_policy.cpp | 181 +++++++++++++++++ src/wallet/lite_rollout_policy.h | 85 ++++++++ src/wallet/lite_wallet_controller.cpp | 8 +- src/wallet/lite_wallet_controller.h | 10 +- src/wallet/lite_wallet_lifecycle_service.cpp | 16 ++ src/wallet/lite_wallet_lifecycle_service.h | 5 +- tests/test_phase4.cpp | 199 +++++++++++++++++++ 12 files changed, 584 insertions(+), 8 deletions(-) create mode 100644 src/wallet/lite_rollout_policy.cpp create mode 100644 src/wallet/lite_rollout_policy.h diff --git a/CLAUDE.md b/CLAUDE.md index 58d73ca..8a3776d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `/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/`. diff --git a/CMakeLists.txt b/CMakeLists.txt index bf5fcdf..c28bf20 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/app.cpp b/src/app.cpp index a071832..8502852 100644 --- a/src/app.cpp +++ b/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 // std::getenv for the lite kill-switch env var #include // 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(); }); } diff --git a/src/config/settings.cpp b/src/config/settings.cpp index c12440c..d033291 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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(); + 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(); + } } if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get(); 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_; diff --git a/src/config/settings.h b/src/config/settings.h index b7f5b87..b4a8ea7 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -244,6 +244,15 @@ public: const std::vector& getLiteServers() const { return lite_servers_; } void setLiteServers(const std::vector& 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 lite_servers_ = { {"https://lite.dragonx.is", "DragonX Lite", true}, {"https://lite1.dragonx.is", "DragonX Lite 1", true}, diff --git a/src/wallet/lite_rollout_policy.cpp b/src/wallet/lite_rollout_policy.cpp new file mode 100644 index 0000000..b9954b0 --- /dev/null +++ b/src/wallet/lite_rollout_policy.cpp @@ -0,0 +1,181 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "lite_rollout_policy.h" + +#include + +#include +#include +#include +#include + +namespace dragonx { +namespace wallet { + +namespace { +using json = nlohmann::json; + +// Numeric dotted core of a version, dropping any "-prerelease" / "+build" suffix. +std::vector parseVersionCore(const std::string& v) +{ + const std::string core = v.substr(0, v.find_first_of("-+")); + std::vector 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(c); + h *= 1099511628211ULL; + } + return static_cast(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(); + if (j.contains("min_version")) m.minVersion = j["min_version"].get(); + if (j.contains("max_version")) m.maxVersion = j["max_version"].get(); + if (j.contains("blocked_versions")) + m.blockedVersions = j["blocked_versions"].get>(); + if (j.contains("rollout_permille")) { + const int p = j["rollout_permille"].get(); + m.rolloutPermille = std::max(0, std::min(1000, p)); + } + if (j.contains("message")) m.message = j["message"].get(); + m.valid = true; + } catch (...) { + m.valid = false; // parse error -> fail-open + } + return m; +} + +} // namespace wallet +} // namespace dragonx diff --git a/src/wallet/lite_rollout_policy.h b/src/wallet/lite_rollout_policy.h new file mode 100644 index 0000000..e905f1c --- /dev/null +++ b/src/wallet/lite_rollout_policy.h @@ -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 +#include + +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 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 diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 2b71930..2fa1427 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -196,7 +196,8 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities, : bridge_(std::make_shared(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::createLinked( WalletCapabilities capabilities, - LiteConnectionSettings connectionSettings) + LiteConnectionSettings connectionSettings, + LiteRolloutDecision rollout) { return std::make_unique( capabilities, std::move(connectionSettings), LiteClientBridge::linkedSdxl(), - LiteWalletControllerOptions{true}); + LiteWalletControllerOptions{true, !rollout.allowed, rollout.message}); } void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& result) diff --git a/src/wallet/lite_wallet_controller.h b/src/wallet/lite_wallet_controller.h index ac92b54..1a813f1 100644 --- a/src/wallet/lite_wallet_controller.h +++ b/src/wallet/lite_wallet_controller.h @@ -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 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 callback) { persist_ = std::move(callback); } diff --git a/src/wallet/lite_wallet_lifecycle_service.cpp b/src/wallet/lite_wallet_lifecycle_service.cpp index 85ebc3a..434b3ea 100644 --- a/src/wallet/lite_wallet_lifecycle_service.cpp +++ b/src/wallet/lite_wallet_lifecycle_service.cpp @@ -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}; diff --git a/src/wallet/lite_wallet_lifecycle_service.h b/src/wallet/lite_wallet_lifecycle_service.h index 39c0b5e..bbe345e 100644 --- a/src/wallet/lite_wallet_lifecycle_service.h +++ b/src/wallet/lite_wallet_lifecycle_service.h @@ -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); diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index fa7ae0a..17cfcb0 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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) {