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
co-authored by Claude Opus 4.8
parent ca14aaddc7
commit b3c2282b53
12 changed files with 584 additions and 8 deletions
@@ -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};