Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4fa286e
feat: add remoteFeatureFlagName to thresholdValut to make a/b tests i…
MarioAslau Dec 16, 2025
452a0ea
feat: add createDeterministicSeed method to keep seed in valid format
MarioAslau Dec 16, 2025
30a9037
Merge branch 'main' into feat/ab-independent
MarioAslau Dec 16, 2025
1c63786
feat: addressing PR comments
MarioAslau Dec 17, 2025
6ec468a
Merge branch 'feat/ab-independent' of https://github.com/MetaMask/cor…
MarioAslau Dec 17, 2025
c4c8cd2
feat: improve createDeterministicSeed method to catch missing metaMet…
MarioAslau Dec 17, 2025
601b2a6
feat: fix lint issue
MarioAslau Dec 17, 2025
526f5fe
feat: Security Issue Fixed: Case-Insensitive Hashing
MarioAslau Dec 17, 2025
cc4cedc
feat: optimising createDeterministicSeed to calculateThresholdForFlag
MarioAslau Dec 18, 2025
827c324
feat: lint fixes
MarioAslau Dec 18, 2025
225859e
feat: update metamask/utils to v11.9.0
MarioAslau Dec 19, 2025
1535a40
feat: removed unused @noble/hashes
MarioAslau Dec 19, 2025
1a94e64
feat: changing treshold to state
MarioAslau Dec 19, 2025
23a5640
feat: change tresholdCache to be part of controller state
MarioAslau Dec 19, 2025
26dfff0
Merge branch 'main' into feat/ab-independent
MarioAslau Dec 19, 2025
c55ab22
feat: addressing BugBot: Non-threshold array feature flags silently d…
MarioAslau Dec 19, 2025
f5cd20f
feat: CHANGELOG updates
MarioAslau Dec 19, 2025
64273ff
feat: add missing unit test
MarioAslau Dec 19, 2025
c033686
feat: fixing more lint issues
MarioAslau Dec 19, 2025
7926b51
feat: state updates now batched & Changelog changes
MarioAslau Dec 19, 2025
7ad8004
feat: change log updates
MarioAslau Dec 19, 2025
b1773f4
feat: small lint fix
MarioAslau Dec 19, 2025
49e52ff
feat: lint fixes
MarioAslau Dec 19, 2025
b244d6e
feat: more lint fixes
MarioAslau Dec 19, 2025
f0474fe
feat: yet another lint fix
MarioAslau Dec 19, 2025
223c5cc
feat: add PR link to Changelogs
MarioAslau Dec 19, 2025
cb91507
feat: fix bugbog normalisation commnet
MarioAslau Dec 19, 2025
686d71b
feat: edge case for metametricsId empty - for users who did not opt in.
MarioAslau Dec 19, 2025
3622e9d
feat: lint
MarioAslau Dec 19, 2025
297369a
Update packages/remote-feature-flag-controller/CHANGELOG.md
MarioAslau Dec 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/remote-feature-flag-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@metamask/controller-utils": "^11.16.0",
"@metamask/messenger": "^0.3.0",
"@metamask/utils": "^11.8.1",
"@noble/hashes": "^1.8.0",
"uuid": "^8.3.2"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -321,11 +321,13 @@
});
await controller.updateRemoteFeatureFlags();

// With MOCK_METRICS_ID + 'testFlagForThreshold' hashed:
// Threshold = 0.380673, which falls in groupB range (0.3 < t <= 0.5)
expect(
controller.state.remoteFeatureFlags.testFlagForThreshold,
).toStrictEqual({
name: 'groupC',
value: 'valueC',
name: 'groupB',
value: 'valueB',
});
});

Expand All @@ -343,6 +345,71 @@
controller.state.remoteFeatureFlags;
expect(nonThresholdFlags).toStrictEqual(MOCK_FLAGS);
});

it('assigns users to different groups for different feature flags', async () => {
// Arrange
const mockFlags = {
featureA: [
{ name: 'groupA1', scope: { type: 'threshold', value: 0.5 }, value: 'A1' },

Check failure on line 353 in packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `·name:·'groupA1',·scope:·{·type:·'threshold',·value:·0.5·},·value:·'A1'` with `⏎············name:·'groupA1',⏎············scope:·{·type:·'threshold',·value:·0.5·},⏎············value:·'A1',⏎·········`
{ name: 'groupA2', scope: { type: 'threshold', value: 1.0 }, value: 'A2' },

Check failure on line 354 in packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `·name:·'groupA2',·scope:·{·type:·'threshold',·value:·1.0·},·value:·'A2'` with `⏎············name:·'groupA2',⏎············scope:·{·type:·'threshold',·value:·1.0·},⏎············value:·'A2',⏎·········`
],
featureB: [
{ name: 'groupB1', scope: { type: 'threshold', value: 0.5 }, value: 'B1' },

Check failure on line 357 in packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `·name:·'groupB1',·scope:·{·type:·'threshold',·value:·0.5·},·value:·'B1'` with `⏎············name:·'groupB1',⏎············scope:·{·type:·'threshold',·value:·0.5·},⏎············value:·'B1',⏎·········`
{ name: 'groupB2', scope: { type: 'threshold', value: 1.0 }, value: 'B2' },

Check failure on line 358 in packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `·name:·'groupB2',·scope:·{·type:·'threshold',·value:·1.0·},·value:·'B2'` with `⏎············name:·'groupB2',⏎············scope:·{·type:·'threshold',·value:·1.0·},⏎············value:·'B2',⏎·········`
],
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: mockFlags,
});
const controller = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
});

// Act
await controller.updateRemoteFeatureFlags();

// Assert - User gets different groups because each flag uses unique seed
const { featureA, featureB } = controller.state.remoteFeatureFlags;
// featureA: hash(MOCK_METRICS_ID + 'featureA') → threshold 0.966682 → groupA2
expect(featureA).toStrictEqual({ name: 'groupA2', value: 'A2' });
// featureB: hash(MOCK_METRICS_ID + 'featureB') → threshold 0.398654 → groupB1
expect(featureB).toStrictEqual({ name: 'groupB1', value: 'B1' });
// Different groups proves independence!
});

it('assigns users to same group for same feature flag on multiple calls', async () => {
// Arrange
const mockFlags = {
testFlag: [
{ name: 'control', scope: { type: 'threshold', value: 0.5 }, value: false },

Check failure on line 385 in packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `·name:·'control',·scope:·{·type:·'threshold',·value:·0.5·},·value:·false` with `⏎············name:·'control',⏎············scope:·{·type:·'threshold',·value:·0.5·},⏎············value:·false,⏎·········`
{ name: 'treatment', scope: { type: 'threshold', value: 1.0 }, value: true },

Check failure on line 386 in packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Replace `·name:·'treatment',·scope:·{·type:·'threshold',·value:·1.0·},·value:·true` with `⏎············name:·'treatment',⏎············scope:·{·type:·'threshold',·value:·1.0·},⏎············value:·true,⏎·········`
],
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: mockFlags,
});

// Act - Create two separate controllers with same metaMetricsId
const controller1 = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
});
await controller1.updateRemoteFeatureFlags();
const firstResult = controller1.state.remoteFeatureFlags.testFlag;

const controller2 = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
});
await controller2.updateRemoteFeatureFlags();
const secondResult = controller2.state.remoteFeatureFlags.testFlag;

// Assert - Same user always gets same group (deterministic)
// testFlag: hash(MOCK_METRICS_ID + 'testFlag') → threshold 0.496587 → control
expect(firstResult).toStrictEqual(secondResult);
expect(firstResult).toStrictEqual({ name: 'control', value: false });
});
});

describe('enable and disable', () => {
Expand Down Expand Up @@ -632,10 +699,11 @@
const { multiVersionABFlag, regularFlag } =
controller.state.remoteFeatureFlags;
// Should select 13.1.0 version and then apply A/B testing to that array
// With MOCK_METRICS_ID threshold, should select groupC (threshold 1.0)
// With MOCK_METRICS_ID + 'multiVersionABFlag' hashed:
// Threshold = 0.094878, which falls in groupA range (t <= 0.3)
expect(multiVersionABFlag).toStrictEqual({
name: 'groupC',
value: { feature: 'C', enabled: true },
name: 'groupA',
value: { feature: 'A', enabled: true },
});
expect(regularFlag).toBe(true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
FeatureFlagScopeValue,
} from './remote-feature-flag-controller-types';
import {
createDeterministicSeed,
generateDeterministicRandomNumber,
isFeatureFlagWithScopeValue,
} from './utils/user-segmentation-utils';
Expand Down Expand Up @@ -278,7 +279,6 @@ export class RemoteFeatureFlagController extends BaseController<
): Promise<FeatureFlags> {
const processedRemoteFeatureFlags: FeatureFlags = {};
const metaMetricsId = this.#getMetaMetricsId();
const thresholdValue = generateDeterministicRandomNumber(metaMetricsId);

for (const [
remoteFeatureFlagName,
Expand All @@ -291,7 +291,12 @@ export class RemoteFeatureFlagController extends BaseController<
continue;
}

if (Array.isArray(processedValue) && thresholdValue) {
if (Array.isArray(processedValue)) {
const deterministicSeed = createDeterministicSeed(
metaMetricsId + remoteFeatureFlagName,
);
const thresholdValue =
generateDeterministicRandomNumber(deterministicSeed);
const selectedGroup = processedValue.find(
(featureFlag): featureFlag is FeatureFlagScopeValue => {
if (!isFeatureFlagWithScopeValue(featureFlag)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v4 as uuidV4 } from 'uuid';

import {
createDeterministicSeed,
generateDeterministicRandomNumber,
isFeatureFlagWithScopeValue,
} from './user-segmentation-utils';
Expand Down Expand Up @@ -41,6 +42,63 @@
};

describe('user-segmentation-utils', () => {
describe('createDeterministicSeed', () => {
it('generates deterministic hash from same input', () => {
// Arrange
const seed = 'testSeed123';

// Act
const hash1 = createDeterministicSeed(seed);
const hash2 = createDeterministicSeed(seed);

// Assert
expect(hash1).toBe(hash2);
expect(hash1).toMatch(/^0x[0-9a-f]{64}$/);

Check failure on line 56 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Use the 'u' flag
});

it('generates different hashes for different inputs', () => {
// Arrange
const seed1 = 'metaMetricsId123';
const seed2 = 'metaMetricsId456';

// Act
const hash1 = createDeterministicSeed(seed1);
const hash2 = createDeterministicSeed(seed2);

// Assert
expect(hash1).not.toBe(hash2);
});

it('generates different hashes when concatenating different flag names', () => {
// Arrange
const metaMetricsId = 'f9e8d7c6-b5a4-4210-9876-543210fedcba';
const flagName1 = 'featureA';
const flagName2 = 'featureB';

// Act
const seed1 = createDeterministicSeed(metaMetricsId + flagName1);
const seed2 = createDeterministicSeed(metaMetricsId + flagName2);

// Assert
expect(seed1).not.toBe(seed2);
expect(seed1).toMatch(/^0x[0-9a-f]{64}$/);

Check failure on line 84 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Use the 'u' flag
expect(seed2).toMatch(/^0x[0-9a-f]{64}$/);

Check failure on line 85 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

Use the 'u' flag
});

it('produces valid hex format for generateDeterministicRandomNumber', () => {
// Arrange
const seed = 'anyStringInput123!@#';

// Act
const hash = createDeterministicSeed(seed);
const randomNumber = generateDeterministicRandomNumber(hash);

// Assert
expect(randomNumber).toBeGreaterThanOrEqual(0);
expect(randomNumber).toBeLessThanOrEqual(1);
});
});

describe('generateDeterministicRandomNumber', () => {
describe('Mobile client new implementation (uuidv4)', () => {
it('generates consistent results for same uuidv4', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Json } from '@metamask/utils';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';

Check failure on line 2 in packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

`uuid` import should occur after import of `@noble/hashes/utils`
import { sha256 } from '@noble/hashes/sha256';
import { bytesToHex } from '@noble/hashes/utils';

import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types';

Expand All @@ -19,6 +21,19 @@
const MAX_UUID_V4_BIGINT = uuidStringToBigInt(MAX_UUID_V4);
const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT;

/**
* Creates a deterministic hex ID by hashing the input seed.
* This ensures consistent group assignment for A/B testing across different feature flags.
*
* @param seed - The seed string to hash (e.g., metaMetricsId + featureFlagName)
* @returns A hex string with '0x' prefix suitable for generateDeterministicRandomNumber
*/
export function createDeterministicSeed(seed: string): string {
const hashBuffer = sha256(seed);
const hash = bytesToHex(hashBuffer);
return `0x${hash}`;
}

/**
* Generates a deterministic random number between 0 and 1 based on a metaMetricsId.
* This is useful for A/B testing and feature flag rollouts where we want
Expand Down
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4632,6 +4632,7 @@ __metadata:
"@metamask/controller-utils": "npm:^11.16.0"
"@metamask/messenger": "npm:^0.3.0"
"@metamask/utils": "npm:^11.8.1"
"@noble/hashes": "npm:^1.8.0"
"@ts-bridge/cli": "npm:^0.6.4"
"@types/jest": "npm:^27.4.1"
deepmerge: "npm:^4.2.2"
Expand Down
Loading