Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
## Initialization
/packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations
/packages/wallet/src/initialization/instances/keyring-controller.ts @MetaMask/accounts-engineers @MetaMask/core-platform
/packages/wallet/src/initialization/instances/remote-feature-flag-controller/ @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform

## Package Release related
/packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ linkStyle default opacity:0.5
wallet --> controller_utils;
wallet --> keyring_controller;
wallet --> messenger;
wallet --> remote_feature_flag_controller;
wallet --> storage_service;
```

Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING:** Wire `ApprovalController` into the default wallet initialization ([#8953](https://github.com/MetaMask/core/pull/8953))
- The default `Wallet` now constructs an `ApprovalController` and registers its `ApprovalController:*` messenger actions. Consumers that pass their own `messenger` and already wire an `ApprovalController` must remove their own before upgrading, or the duplicate registration will collide.
- Adds an `approvalController` slot to `instanceOptions` with `showApprovalRequest` (the callback that surfaces pending approval requests to the user; defaults to a no-op) and `typesExcludedFromRateLimiting` (the approval types exempt from per-origin rate limiting; defaults to a baseline of EVM approval types). Both let consumers (extension, mobile, wallet-cli) inject their platform-specific values.
- Wire `RemoteFeatureFlagController` into the default wallet initialization ([#8969](https://github.com/MetaMask/core/pull/8969))
- Adds a `remoteFeatureFlagController` slot to `instanceOptions` with `clientConfigApiService`, `getMetaMetricsId`, `clientVersion`, `prevClientVersion`, `fetchInterval`, and `disabled`. Each value differs per platform (extension, mobile, wallet-cli), so all are injectable with inert defaults; `prevClientVersion` lets consumers trigger feature-flag cache invalidation when the client version changes between sessions.

## [2.0.0]

Expand Down
1 change: 1 addition & 0 deletions packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@metamask/controller-utils": "^12.1.0",
"@metamask/keyring-controller": "^26.0.0",
"@metamask/messenger": "^1.2.0",
"@metamask/remote-feature-flag-controller": "^4.2.1",
"@metamask/scure-bip39": "^2.1.1",
"@metamask/storage-service": "^1.0.1",
"@metamask/utils": "^11.9.0"
Expand Down
50 changes: 50 additions & 0 deletions packages/wallet/src/Wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,4 +264,54 @@ describe('Wallet', () => {
).toBe('bar');
});
});

describe('RemoteFeatureFlagController', () => {
it('is wired and exposes its state on the wallet messenger', async () => {
const wallet = await setupWallet();
const { messenger } = wallet;

expect(
messenger.call('RemoteFeatureFlagController:getState'),
).toStrictEqual({
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
});
});

it('routes injected instanceOptions through to the controller', async () => {
// Proves the end-to-end path: the camelCased `remoteFeatureFlagController`
// option key reaches `initialize` -> `init` -> the controller. An injected
// service returns a known flag, which then appears in state fetched over
// the shared messenger.
const wallet = new Wallet({
instanceOptions: {
keyringController: { encryptor: new MockEncryptor() },
storageService: { storage: new InMemoryStorageAdapter() },
remoteFeatureFlagController: {
clientConfigApiService: {
fetchRemoteFeatureFlags: async (): Promise<{
remoteFeatureFlags: Record<string, boolean>;
cacheTimestamp: number;
}> => ({
remoteFeatureFlags: { testFlag: true },
cacheTimestamp: Date.now(),
}),
},
},
},
});
const { messenger } = wallet;

await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
);

expect(
messenger.call('RemoteFeatureFlagController:getState')
.remoteFeatureFlags,
).toStrictEqual({ testFlag: true });
});
});
});
1 change: 1 addition & 0 deletions packages/wallet/src/initialization/instances/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { approvalController } from './approval-controller/approval-controller';
export { keyringController } from './keyring-controller';
export { remoteFeatureFlagController } from './remote-feature-flag-controller/remote-feature-flag-controller';
export { storageService } from './storage-service';
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { Messenger } from '@metamask/messenger';
import { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller';

import { defaultConfigurations } from '../../defaults';
import type {
DefaultActions,
DefaultEvents,
RootMessenger,
} from '../../defaults';
import { remoteFeatureFlagController } from './remote-feature-flag-controller';

/**
* Creates a root messenger for use in tests.
*
* @returns A root messenger.
*/
function getRootMessenger(): RootMessenger<DefaultActions, DefaultEvents> {
return new Messenger({ namespace: 'Root' });
}

describe('remoteFeatureFlagController', () => {
it('is registered as a default initialization configuration', () => {
// Proves the controller is part of the default ensemble that `initialize()`
// wires, without constructing a `Wallet` (which keeps this PR independent of
// the constructor-options shape).
expect(Object.values(defaultConfigurations)).toContain(
remoteFeatureFlagController,
);
});

it('initializes a RemoteFeatureFlagController with default state', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {},
});

expect(instance).toBeInstanceOf(RemoteFeatureFlagController);
expect(instance.state).toStrictEqual({
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
});
});

it('forwards the provided state to the controller', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: {
remoteFeatureFlags: { testFlag: true },
cacheTimestamp: 12345,
},
messenger,
options: {},
});

expect(instance.state.remoteFeatureFlags).toStrictEqual({ testFlag: true });
});

it('falls back to inert defaults that fetch no flags when no options are provided', async () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {},
});

// Exercises the default `clientConfigApiService` and `getMetaMetricsId`:
// the cache is expired (timestamp 0), so this fetches via the inert default
// service, which returns an empty flag set.
await instance.updateRemoteFeatureFlags();

expect(instance.state.remoteFeatureFlags).toStrictEqual({});
});

it('uses the injected clientConfigApiService, getMetaMetricsId, and clientVersion', async () => {
const fetchRemoteFeatureFlags = jest.fn().mockResolvedValue({
remoteFeatureFlags: { testFlag: true },
cacheTimestamp: Date.now(),
});
const getMetaMetricsId = jest.fn(() => 'test-metrics-id');
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {
clientConfigApiService: { fetchRemoteFeatureFlags },
getMetaMetricsId,
clientVersion: '1.2.3',
},
});

await instance.updateRemoteFeatureFlags();

expect(fetchRemoteFeatureFlags).toHaveBeenCalledTimes(1);
expect(getMetaMetricsId).toHaveBeenCalled();
expect(instance.state.remoteFeatureFlags).toStrictEqual({ testFlag: true });
});

it('does not fetch flags when initialized as disabled', async () => {
const fetchRemoteFeatureFlags = jest.fn();
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {
clientConfigApiService: { fetchRemoteFeatureFlags },
disabled: true,
},
});

await instance.updateRemoteFeatureFlags();

expect(fetchRemoteFeatureFlags).not.toHaveBeenCalled();
});

it('invalidates the cache when prevClientVersion differs from clientVersion', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: {
remoteFeatureFlags: { testFlag: true },
cacheTimestamp: Date.now(),
},
messenger,
options: { clientVersion: '2.0.0', prevClientVersion: '1.0.0' },
});

// A version change resets the cache timestamp to 0 so the next update
// refetches rather than serving stale flags from a previous version.
expect(instance.state.cacheTimestamp).toBe(0);
});

it('preserves the cache when prevClientVersion matches clientVersion', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: {
remoteFeatureFlags: { testFlag: true },
cacheTimestamp: 5000,
},
messenger,
// Same version: invalidation must be conditional, so the timestamp is
// preserved (this proves both versions are forwarded to the right slots,
// not that the controller always zeroes the cache).
options: { clientVersion: '2.0.0', prevClientVersion: '2.0.0' },
});

expect(instance.state.cacheTimestamp).toBe(5000);
});

it('does not throw with the default clientVersion', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

// The default '0.0.0' is a valid SemVer; the controller throws on invalid
// versions, so this proves a headless consumer can construct it.
expect(() =>
remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {},
}),
).not.toThrow();
});

it('surfaces the controller throw on an invalid clientVersion', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

expect(() =>
remoteFeatureFlagController.init({
state: undefined,
messenger,
options: { clientVersion: 'not-semver' },
}),
).toThrow('Invalid clientVersion');
});

it('forwards a custom fetchInterval to the controller', async () => {
const fetchRemoteFeatureFlags = jest.fn().mockResolvedValue({
remoteFeatureFlags: {},
cacheTimestamp: Date.now(),
});
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
// A non-expired cache (recent timestamp) combined with a very large
// fetchInterval means the cache is considered fresh, so no fetch happens.
state: { remoteFeatureFlags: {}, cacheTimestamp: Date.now() },
messenger,
options: {
clientConfigApiService: { fetchRemoteFeatureFlags },
fetchInterval: 60 * 60 * 1000,
},
});

await instance.updateRemoteFeatureFlags();

expect(fetchRemoteFeatureFlags).not.toHaveBeenCalled();
});

it('exposes its state through the root messenger', () => {
const rootMessenger = getRootMessenger();
const messenger = remoteFeatureFlagController.getMessenger(rootMessenger);

remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {},
});

expect(
rootMessenger.call('RemoteFeatureFlagController:getState'),
).toStrictEqual({
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
});
});
});
Loading
Loading