From 0f9b03dd48f1fd60d2a1858915ab0a8a7f8f2d7f Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Thu, 18 Jun 2026 19:31:45 -0400 Subject: [PATCH 1/2] chore: add Cursor rule for version-gated feature flags Track repo-specific Cursor rule so contributors use validatedVersionGatedFeatureFlag from app/util/remoteFeatureFlag instead of duplicating version-check helpers. Co-authored-by: Cursor --- .cursor/rules/version-gated-feature-flags.mdc | 95 +++++++++++++++++++ .gitignore | 4 +- AGENTS.md | 1 + 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 .cursor/rules/version-gated-feature-flags.mdc diff --git a/.cursor/rules/version-gated-feature-flags.mdc b/.cursor/rules/version-gated-feature-flags.mdc new file mode 100644 index 00000000000..6f534d39c23 --- /dev/null +++ b/.cursor/rules/version-gated-feature-flags.mdc @@ -0,0 +1,95 @@ +--- +description: Use shared version-gated feature flag helpers; never duplicate hasMinimumRequiredVersion or validatedVersionGatedFeatureFlag +globs: app/**/*.{ts,tsx} +alwaysApply: false +--- + +# Version-Gated Feature Flags + +## Canonical source (SSOT) + +All version-gated feature flag logic lives in `app/util/remoteFeatureFlag/index.ts`: + +- `validatedVersionGatedFeatureFlag` — preferred for remote flags shaped as `{ enabled, minimumVersion }` (or progressive-rollout wrappers) +- `hasMinimumRequiredVersion` — version comparison only; import when needed, never redefine + +## Required patterns + +### ✅ Use `validatedVersionGatedFeatureFlag` in selectors + +```typescript +import { validatedVersionGatedFeatureFlag, VersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag'; + +export const selectMyFeatureEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = remoteFeatureFlags?.myFeature as unknown as VersionGatedFeatureFlag; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? false; + }, +); +``` + +### ✅ Non-standard flag shapes — map, don't duplicate + +Configs that use `active` instead of `enabled` (e.g. `depositConfig`) must map to the canonical shape and call the shared helper: + +```typescript +import { validatedVersionGatedFeatureFlag } from '../../../util/remoteFeatureFlag'; + +return ( + validatedVersionGatedFeatureFlag({ + enabled: depositConfig.active ?? false, + minimumVersion: depositConfig.minimumVersion ?? '', + }) ?? false +); +``` + +### ✅ Import `hasMinimumRequiredVersion` only from the util + +```typescript +import { hasMinimumRequiredVersion } from '../../../util/remoteFeatureFlag'; +``` + +Never copy the `compare-versions` + `getVersion` pattern into feature modules. + +## Forbidden patterns + +### ❌ Do not define local version-gating helpers + +```typescript +// FORBIDDEN — duplicate of app/util/remoteFeatureFlag +function hasMinimumRequiredVersion(minVersion: string) { ... } +const validatedVersionGatedFeatureFlag = (flag: unknown) => { ... } +``` + +### ❌ Do not inline version checks for feature flags + +```typescript +// FORBIDDEN +import { getVersion } from 'react-native-device-info'; +import compareVersions from 'compare-versions'; + +const ok = compareVersions.compare(getVersion(), flag.minimumVersion, '>='); +``` + +### ❌ Do not add duplicate utils under feature folders + +Do not create files like: + +- `app/components/UI/**/utils/hasMinimumRequiredVersion.ts` +- `app/core/redux/slices/**/hasMinimumRequiredVersion.ts` + +## Where logic belongs + +| Layer | Responsibility | +|-------|----------------| +| `RemoteFeatureFlagController` | Fetch/store raw flags — **no** client version gating | +| `app/util/remoteFeatureFlag` | Version comparison + `validatedVersionGatedFeatureFlag` | +| Selectors (`featureFlagController`, `**/selectors/featureFlags`) | Call shared helpers; return `boolean` | +| Hooks / components | `useSelector(selectXEnabled)` — not local version math | + +## Enforcement + +- **REJECT** new local definitions of `hasMinimumRequiredVersion` or `validatedVersionGatedFeatureFlag` +- **REJECT** new `compare-versions` + `getVersion` usage for feature-flag gating outside `app/util/remoteFeatureFlag` +- **REQUIRE** imports from `app/util/remoteFeatureFlag` for all version-gated flag checks diff --git a/.gitignore b/.gitignore index 2b55b6bd102..91404284a38 100644 --- a/.gitignore +++ b/.gitignore @@ -217,10 +217,12 @@ release-signoffs.json .claude/skills/ .claude/commands/ .agents/skills/ -.cursor/rules/ +.cursor/rules/* +!.cursor/rules/*.mdc .cursor/skills/ .cursor/commands/ # Carve-outs: IDE/bugbot config that must stay tracked +!.cursor/rules/*.mdc !.cursor/BUGBOT.md !.cursor/hooks.json !.cursor/worktrees.json diff --git a/AGENTS.md b/AGENTS.md index 57823b449d3..ffa22f3bdd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ General coding, UI, deeplink-handler, and PR-creation guidance now lives in the - **Components**: Design system first → `component-library` second → custom last - **Styling**: Use `useTailwind()` hook, `Box`/`Text` components, design tokens - **Testing**: Mandatory for all code, AAA pattern, mock everything external +- **Version-gated feature flags**: Use shared helpers from `app/util/remoteFeatureFlag` — see [`.cursor/rules/version-gated-feature-flags.mdc`](.cursor/rules/version-gated-feature-flags.mdc) - **Commands**: ONLY use yarn (never npm/npx) ## Environment Setup From 6241d2d1ff4233ec411c0c861d892cdd3d99d548 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 22 Jun 2026 08:45:24 -0400 Subject: [PATCH 2/2] update docs --- .cursor/rules/version-gated-feature-flags.mdc | 2 + AGENTS.md | 13 +-- docs/compliance.md | 4 +- docs/musd/musd-convert-list.md | 7 +- docs/perps/perps-feature-flags.md | 22 +++-- docs/predict/live-nfl-architecture.md | 7 +- .../tasks/00-feature-flag-and-types.md | 5 + docs/readme/version-gated-feature-flags.md | 97 +++++++++++++++++++ 8 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 docs/readme/version-gated-feature-flags.md diff --git a/.cursor/rules/version-gated-feature-flags.mdc b/.cursor/rules/version-gated-feature-flags.mdc index 6f534d39c23..14bb74ffa18 100644 --- a/.cursor/rules/version-gated-feature-flags.mdc +++ b/.cursor/rules/version-gated-feature-flags.mdc @@ -6,6 +6,8 @@ alwaysApply: false # Version-Gated Feature Flags +> Canonical human guide: [`docs/readme/version-gated-feature-flags.md`](../../docs/readme/version-gated-feature-flags.md) + ## Canonical source (SSOT) All version-gated feature flag logic lives in `app/util/remoteFeatureFlag/index.ts`: diff --git a/AGENTS.md b/AGENTS.md index ffa22f3bdd9..389b103dfb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,11 +101,12 @@ scripts/ # Build and automation scripts **Detailed guidelines are in `docs/testing/`** (canonical, in-repo) and the `mms-*` skill set installed via `yarn skills` (Cursor / Codex / Claude harnesses): -| Guide | Scope | -| ------------------------------------------------------------------------------ | ------------------------------------------------- | -| [`docs/testing/unit-testing.md`](docs/testing/unit-testing.md) | `*.test.*` files — test patterns, mocking, AAA | -| [`docs/testing/e2e-testing.md`](docs/testing/e2e-testing.md) | Detox smoke/regression — Page Objects, gestures | -| [`docs/testing/component-view-tests.md`](docs/testing/component-view-tests.md) | `*.view.test.tsx` — framework, presets, renderers | +| Guide | Scope | +| ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| [`docs/testing/unit-testing.md`](docs/testing/unit-testing.md) | `*.test.*` files — test patterns, mocking, AAA | +| [`docs/testing/e2e-testing.md`](docs/testing/e2e-testing.md) | Detox smoke/regression — Page Objects, gestures | +| [`docs/testing/component-view-tests.md`](docs/testing/component-view-tests.md) | `*.view.test.tsx` — framework, presets, renderers | +| [`docs/readme/version-gated-feature-flags.md`](docs/readme/version-gated-feature-flags.md) | Version-gated remote flags — `validatedVersionGatedFeatureFlag` in selectors | General coding, UI, deeplink-handler, and PR-creation guidance now lives in the centralized `mms-*` skill set installed via `yarn skills` (see `.agents/skills/mms-*` after sync). @@ -115,7 +116,7 @@ General coding, UI, deeplink-handler, and PR-creation guidance now lives in the - **Components**: Design system first → `component-library` second → custom last - **Styling**: Use `useTailwind()` hook, `Box`/`Text` components, design tokens - **Testing**: Mandatory for all code, AAA pattern, mock everything external -- **Version-gated feature flags**: Use shared helpers from `app/util/remoteFeatureFlag` — see [`.cursor/rules/version-gated-feature-flags.mdc`](.cursor/rules/version-gated-feature-flags.mdc) +- **Version-gated feature flags**: Use `validatedVersionGatedFeatureFlag` from `app/util/remoteFeatureFlag` in selectors — see [`docs/readme/version-gated-feature-flags.md`](docs/readme/version-gated-feature-flags.md) and [`.cursor/rules/version-gated-feature-flags.mdc`](.cursor/rules/version-gated-feature-flags.mdc) - **Commands**: ONLY use yarn (never npm/npx) ## Environment Setup diff --git a/docs/compliance.md b/docs/compliance.md index 5a2ae6ad226..fb6da5735a2 100644 --- a/docs/compliance.md +++ b/docs/compliance.md @@ -50,7 +50,7 @@ To enable compliance: 1. Set `complianceEnabled: true` in LaunchDarkly (or via the local feature flag override screen in dev builds). 2. Compliance status will be fetched per-address whenever a gated action is triggered. -> **Version gating:** The feature flag uses `validatedVersionGatedFeatureFlag`, which compares against the **native binary version** reported by `react-native-device-info`'s `getVersion()` — not `package.json`. If your device has a stale build installed, the version check may fail even if the flag is enabled. Do a clean native rebuild to pick up the correct version. +> **Version gating:** The feature flag uses `validatedVersionGatedFeatureFlag` in `selectComplianceEnabled`, which compares against the **native binary version** reported by `react-native-device-info`'s `getVersion()` — not `package.json`. If your device has a stale build installed, the version check may fail even if the flag is enabled. Do a clean native rebuild to pick up the correct version. ### Feature flag selector @@ -60,6 +60,8 @@ import { selectComplianceEnabled } from 'app/selectors/featureFlagController/com const isEnabled = useSelector(selectComplianceEnabled); ``` +The selector resolves the remote `{ enabled, minimumVersion }` payload via `validatedVersionGatedFeatureFlag`. See [`docs/readme/version-gated-feature-flags.md`](./readme/version-gated-feature-flags.md). + ## Usage in Flows ### Option 1: `gate()` from `useComplianceGate` (recommended for action guards) diff --git a/docs/musd/musd-convert-list.md b/docs/musd/musd-convert-list.md index 9dcd1f6abdf..07e6b676ceb 100644 --- a/docs/musd/musd-convert-list.md +++ b/docs/musd/musd-convert-list.md @@ -461,9 +461,14 @@ export const selectMusdConversionStatuses = createSelector( **Path:** `app/components/UI/Earn/selectors/featureFlags/index.ts` -Add new version-gated flag with local fallback (following the same pattern as other mUSD flags): +Add new version-gated flag with local fallback (see [`docs/readme/version-gated-feature-flags.md`](../../readme/version-gated-feature-flags.md)): ```typescript +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; + export const selectMusdQuickConvertEnabledFlag = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { diff --git a/docs/perps/perps-feature-flags.md b/docs/perps/perps-feature-flags.md index a10df851282..06ab9f314af 100644 --- a/docs/perps/perps-feature-flags.md +++ b/docs/perps/perps-feature-flags.md @@ -7,7 +7,7 @@ Framework for controlling Perps feature availability through LaunchDarkly with l **Key Design Principles:** - LaunchDarkly is the single source of truth for feature enablement -- Version-gated flags ensure features only activate on compatible app versions +- Version-gated flags use `validatedVersionGatedFeatureFlag` from `app/util/remoteFeatureFlag` (see [Version-gated feature flags](../readme/version-gated-feature-flags.md)) - Local environment variables provide development/testing fallback - Graceful degradation when LaunchDarkly is unavailable @@ -74,6 +74,11 @@ See [Perps A/B Testing Framework](./perps-ab-testing.md) for variant-based flags **File:** `app/components/UI/Perps/selectors/featureFlags/index.ts` ```typescript +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; + /** * Selector for My Feature flag * Controls visibility of My Feature in the UI @@ -230,12 +235,14 @@ These flags are managed via `FeatureFlagConfigurationService` and control runtim ### Version Gating -The `minimumVersion` field ensures features only activate on compatible app versions: +The `minimumVersion` field ensures features only activate on compatible app versions. **Do not** compare versions in selectors or components — use `validatedVersionGatedFeatureFlag`: -- **Format:** Semantic version string (e.g., `"7.60.0"`) -- **Comparison:** Uses `compare-versions` library with `>=` operator +- **Format:** Semantic version string (e.g. `"7.60.0"`) +- **Evaluation:** `validatedVersionGatedFeatureFlag(remoteFlag)` returns `true` only when `enabled` is true **and** the native app version is `>= minimumVersion` - **Use case:** Prevent feature activation on older app versions that lack required code +See [`docs/readme/version-gated-feature-flags.md`](../readme/version-gated-feature-flags.md) for the full pattern and non-standard flag shapes. + --- ## Troubleshooting @@ -249,8 +256,8 @@ The `minimumVersion` field ensures features only activate on compatible app vers ### Version Gating Not Working 1. **Verify `minimumVersion` format:** Must be valid semver string -2. **Check app version:** `getVersion()` from `react-native-device-info` -3. **Check comparison:** Uses `>=` operator +2. **Check native app version:** `getVersion()` from `react-native-device-info` (used inside `validatedVersionGatedFeatureFlag`) — not `package.json` +3. **Confirm selector uses the helper:** `validatedVersionGatedFeatureFlag(remoteFlag) ?? localFallback` — not inline `compare-versions` ### Local Flag Not Overriding @@ -265,7 +272,8 @@ The `minimumVersion` field ensures features only activate on compatible app vers - **Selectors:** `app/components/UI/Perps/selectors/featureFlags/index.ts` - **Mocks:** `app/components/UI/Perps/mocks/remoteFeatureFlagMocks.ts` - **Tests:** `app/components/UI/Perps/selectors/featureFlags/index.test.ts` -- **Version validation:** `app/util/remoteFeatureFlag/index.ts` +- **Version validation:** `app/util/remoteFeatureFlag/index.ts` (`validatedVersionGatedFeatureFlag`) +- **Version-gated flags guide:** `docs/readme/version-gated-feature-flags.md` - **Controller init:** `app/core/Engine/controllers/remote-feature-flag-controller-init.ts` - **Configuration service:** `app/controllers/perps/services/FeatureFlagConfigurationService.ts` diff --git a/docs/predict/live-nfl-architecture.md b/docs/predict/live-nfl-architecture.md index eb7d9d597ba..11af44c178e 100644 --- a/docs/predict/live-nfl-architecture.md +++ b/docs/predict/live-nfl-architecture.md @@ -8,10 +8,15 @@ The Live NFL feature introduces real-time sports betting functionality with WebS ## Feature Flag -The feature is gated behind `predictLiveNflEnabled`: +The feature is gated behind `predictLiveNflEnabled` using `validatedVersionGatedFeatureFlag` (see [`docs/readme/version-gated-feature-flags.md`](../readme/version-gated-feature-flags.md)): ```typescript // app/components/UI/Predict/selectors/featureFlags/index.ts +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; + export const selectPredictLiveNflEnabled = createSelector( selectRemoteFeatureFlags, (remoteFeatureFlags) => { diff --git a/docs/predict/tasks/00-feature-flag-and-types.md b/docs/predict/tasks/00-feature-flag-and-types.md index 7a12e943c0f..ed00714b467 100644 --- a/docs/predict/tasks/00-feature-flag-and-types.md +++ b/docs/predict/tasks/00-feature-flag-and-types.md @@ -27,6 +27,11 @@ Set up the feature flag for Live NFL and define all necessary TypeScript types f Create/update `app/components/UI/Predict/selectors/featureFlags/index.ts`: ```typescript +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../../../util/remoteFeatureFlag'; + /** * Selector for Predict Live NFL feature enablement */ diff --git a/docs/readme/version-gated-feature-flags.md b/docs/readme/version-gated-feature-flags.md new file mode 100644 index 00000000000..26d6b0cafd8 --- /dev/null +++ b/docs/readme/version-gated-feature-flags.md @@ -0,0 +1,97 @@ +# Version-Gated Feature Flags + +Canonical guide for evaluating remote feature flags that include a minimum app version. Applies to all teams (Ramp, Perps, Money, Predict, platform selectors, etc.). + +## SSOT + +| Concern | Location | +| ------------------------------------ | -------------------------------------------------------------------------------------- | +| Version comparison + flag validation | [`app/util/remoteFeatureFlag/index.ts`](../../app/util/remoteFeatureFlag/index.ts) | +| Raw flag storage | `RemoteFeatureFlagController.remoteFeatureFlags` (no client version gating) | +| Boolean for UI / hooks | Selectors under `app/selectors/featureFlagController/` or `**/selectors/featureFlags/` | + +**Always use** `validatedVersionGatedFeatureFlag` in selectors. **Never** duplicate `hasMinimumRequiredVersion` or `validatedVersionGatedFeatureFlag` in feature modules. + +`hasMinimumRequiredVersion` is a lower-level helper used inside `validatedVersionGatedFeatureFlag`. Import it from the util only when you need a standalone version check — do not reimplement it. + +## Flag shapes + +### Standard (preferred) + +LaunchDarkly JSON: + +```json +{ + "enabled": true, + "minimumVersion": "7.65.0" +} +``` + +Progressive-rollout wrappers (`{ name, value: { enabled, minimumVersion } }`) are normalized automatically. + +### Non-standard configs + +Some flags use different property names (e.g. `depositConfig` uses `active` instead of `enabled`). **Map** to the canonical shape before calling the helper: + +```typescript +validatedVersionGatedFeatureFlag({ + enabled: depositConfig.active ?? false, + minimumVersion: depositConfig.minimumVersion ?? '', +}) ?? false; +``` + +### Multi-version flags (controller-processed) + +`RemoteFeatureFlagController` resolves flags shaped as `{ versions: { "7.53.0": value, "7.60.0": value } }` at fetch time using `clientVersion`. Consumers read the **processed** value from state — no `validatedVersionGatedFeatureFlag` call needed for that shape. + +## Implementation pattern + +### 1. Selector (required) + +```typescript +import { createSelector } from 'reselect'; +import { selectRemoteFeatureFlags } from '../index'; // adjust path +import { + validatedVersionGatedFeatureFlag, + type VersionGatedFeatureFlag, +} from '../../../util/remoteFeatureFlag'; + +export const selectMyFeatureEnabled = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const localFlag = process.env.MM_MY_FEATURE_ENABLED === 'true'; + const remoteFlag = + remoteFeatureFlags?.myFeature as unknown as VersionGatedFeatureFlag; + + return validatedVersionGatedFeatureFlag(remoteFlag) ?? localFlag; + }, +); +``` + +- Remote flag wins when valid; `undefined` from the helper triggers env/local fallback. +- When `OVERRIDE_REMOTE_FEATURE_FLAGS=true`, the helper returns `undefined` so local overrides apply. + +### 2. Hook or component + +```typescript +const isEnabled = useSelector(selectMyFeatureEnabled); +``` + +Do not call `getVersion()`, `compare-versions`, or local version helpers in hooks/components. + +## Version source + +`validatedVersionGatedFeatureFlag` compares against the **native binary version** from `react-native-device-info`'s `getVersion()` — not `package.json`. After a version bump, do a clean native rebuild if gating behaves unexpectedly. + +## Forbidden + +- Local `function hasMinimumRequiredVersion(...)` copies +- Inline `compareVersions.compare(getVersion(), minimumVersion, '>=')` for feature flags +- Duplicate util files under `app/components/UI/**/utils/` or `app/core/redux/slices/**/` + +## Related + +- [Perps feature flags](../perps/perps-feature-flags.md) — team-specific examples +- [Compliance OFAC gating](../compliance.md#feature-flag) — real selector using this pattern +- [`.cursor/rules/version-gated-feature-flags.mdc`](../../.cursor/rules/version-gated-feature-flags.mdc) — Cursor agent rule +- External: [MetaMask remote feature flags](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md)