Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: stx optInModalMinVersion feature flag - UI part #24727

Closed
wants to merge 12 commits into from
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
"@metamask/snaps-rpc-methods": "^9.0.0",
"@metamask/snaps-sdk": "^4.2.0",
"@metamask/snaps-utils": "^7.4.0",
"@metamask/transaction-controller": "^29.0.0",
"@metamask/transaction-controller": "^29.0.2",
"@metamask/user-operation-controller": "^8.0.1",
"@metamask/utils": "^8.2.1",
"@ngraveio/bc-ur": "^1.1.12",
Expand Down
91 changes: 91 additions & 0 deletions shared/lib/semversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import ExtensionPlatform from '../../app/scripts/platforms/extension';
import { Platform } from '../../types/global';
import {
parseVersion,
compareVersions,
SemVersion,
getMetamaskVersion,
} from './semversion';

describe('versionUtils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('parseVersion', () => {
it('parses valid version strings', () => {
expect(parseVersion('1.2.3')).toStrictEqual({
major: 1,
minor: 2,
patch: 3,
});
});

it('returns null for empty strings', () => {
expect(parseVersion('')).toBeNull();
});

it('returns null for invalid formats', () => {
expect(parseVersion('1.2')).toBeNull();
expect(parseVersion('1.2.x')).toBeNull();
expect(parseVersion('a.b.c')).toBeNull();
});

it('returns null for negative numbers', () => {
expect(parseVersion('-1.2.3')).toBeNull();
});

it('returns null for semver strings with extra characters', () => {
expect(parseVersion('1.2.3-beta')).toBeNull();
});
});

describe('compareVersions', () => {
it('compares versions based on major, minor, and patch numbers', () => {
const v1: SemVersion = { major: 1, minor: 0, patch: 0 };
const v2: SemVersion = { major: 2, minor: 0, patch: 0 };
const v3: SemVersion = { major: 1, minor: 1, patch: 0 };
const v4: SemVersion = { major: 1, minor: 1, patch: 1 };
expect(compareVersions(v1, v2)).toBeLessThan(0);
expect(compareVersions(v2, v1)).toBeGreaterThan(0);
expect(compareVersions(v3, v4)).toBeLessThan(0);
expect(compareVersions(v4, v3)).toBeGreaterThan(0);
expect(compareVersions(v1, v1)).toBe(0);
});
});

describe('getMetamaskVersion', () => {
const getVersionMock = jest.fn();
const mockExtensionPlatform = {
getVersion: getVersionMock,
} as unknown as ExtensionPlatform;

let originalPlatform: Platform;

beforeAll(() => {
originalPlatform = global.platform;
global.platform = mockExtensionPlatform;
});

beforeEach(() => {
getVersionMock.mockClear();
});

afterAll(() => {
global.platform = originalPlatform;
});

it('should return the parsed Metamask version', () => {
getVersionMock.mockReturnValue('1.2.3');
expect(getMetamaskVersion()).toStrictEqual({
major: 1,
minor: 2,
patch: 3,
});
});

it('should return null if parsing the Metamask version fails', () => {
getVersionMock.mockReturnValue('invalid');
expect(getMetamaskVersion()).toBeNull();
});
});
});
53 changes: 53 additions & 0 deletions shared/lib/semversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* A simple utility to parse and compare semver versions.
*/

import type ExtensionPlatform from '../../app/scripts/platforms/extension';

/**
* A semver version object.
*/
export type SemVersion = {
major: number;
minor: number;
patch: number;
};

/**
* Parse a semver version string into a SemVersion object.
*
* @param version - The version string to parse, of the form "major.minor.patch". e.g. "1.2.3"
* @returns
*/
export const parseVersion = (version: string = ''): SemVersion | null => {
const [major, minor, patch] = version.split('.').map(Number);

// if version is not a valid semver, return false
if ([major, minor, patch].some((num) => isNaN(num) || num < 0)) {
return null;
}
return { major, minor, patch };
};

/**
* Compare two SemVersion objects.
*
* @param a
* @param b
* @returns negative if a < b, zero if a === b, positive if a > b
*/
export const compareVersions = (a: SemVersion, b: SemVersion): number => {
if (a.major !== b.major) {
return a.major - b.major;
}
if (a.minor !== b.minor) {
return a.minor - b.minor;
}
return a.patch - b.patch;
};

/**
* Get the version of the extension.
*/
export const getMetamaskVersion = () =>
parseVersion((global.platform as ExtensionPlatform).getVersion());
42 changes: 31 additions & 11 deletions shared/modules/selectors/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
import { getCurrentChainId } from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file.
import { getNetworkNameByChainId } from '../feature-flags';

export type SmartTransactionsFeatureFlags = {
extensionActive?: boolean;
mobileActive?: boolean;
expectedDeadline?: number;
maxDeadline?: number;
returnTxHashAsap?: boolean;
optInModalMinVersion?: string;
};

export type ChainSpecificFeatureFlags = {
extensionActive: boolean;
mobileActive: boolean;
smartTransactions: SmartTransactionsFeatureFlags;
};

/**
* All feature flags. Currently only smartTransactions.
*/
export type FeatureFlags = {
smartTransactions?: SmartTransactionsFeatureFlags;
};

type FeatureFlagsMetaMaskState = {
metamask: {
swapsState: {
swapsFeatureFlags: {
[key: string]: {
extensionActive: boolean;
mobileActive: boolean;
smartTransactions: {
expectedDeadline?: number;
maxDeadline?: number;
returnTxHashAsap?: boolean;
};
};
[key: string]:
| SmartTransactionsFeatureFlags
| ChainSpecificFeatureFlags;
smartTransactions: SmartTransactionsFeatureFlags;
};
};
};
};

export function getFeatureFlagsByChainId(state: FeatureFlagsMetaMaskState) {
export function getFeatureFlagsByChainId(
state: FeatureFlagsMetaMaskState,
): FeatureFlags | null {
const chainId = getCurrentChainId(state);
const networkName = getNetworkNameByChainId(chainId);
const featureFlags = state.metamask.swapsState?.swapsFeatureFlags;
Expand All @@ -29,7 +48,8 @@ export function getFeatureFlagsByChainId(state: FeatureFlagsMetaMaskState) {
return {
smartTransactions: {
...featureFlags.smartTransactions,
...featureFlags[networkName].smartTransactions,
...(featureFlags[networkName] as ChainSpecificFeatureFlags)
.smartTransactions,
},
};
}
76 changes: 76 additions & 0 deletions shared/modules/selectors/smart-transactions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import ExtensionPlatform from '../../../app/scripts/platforms/extension';
import { Platform } from '../../../types/global';
import { getMeetsMinimumVersionToShowOptInModal } from './smart-transactions';

const minVersionMock = '1.2.3';

const makeMinVersionFlags = (optInModalMinVersion: string) => ({
smartTransactions: { optInModalMinVersion },
});

describe('getMeetsMinimumVersionToShowOptInModal', () => {
const getCurrentVersionMock = jest.fn();
const mockExtensionPlatform = {
getVersion: getCurrentVersionMock,
} as unknown as ExtensionPlatform;

let originalPlatform: Platform;

beforeAll(() => {
originalPlatform = global.platform;
global.platform = mockExtensionPlatform;
});

beforeEach(() => {
getCurrentVersionMock.mockClear();
});

afterAll(() => {
global.platform = originalPlatform;
});

describe('normal behavior', () => {
const featureFlags = makeMinVersionFlags(minVersionMock);

it.each([
// [expectedOutcome, condition, currentVersion]
[true, 'equal to the minimum required version', '1.2.3'],
[true, 'exceeds the minimum required version', '1.2.4'],
[false, 'below the minimum required version', '1.2.2'],
])(
'returns %s when the current version is %s, testing condition: %s',
(expectedResult, _description, currentVersion) => {
getCurrentVersionMock.mockReturnValue(currentVersion);
const selector =
getMeetsMinimumVersionToShowOptInModal.resultFunc(featureFlags);
expect(selector).toBe(expectedResult);
},
);
});

describe('edge cases', () => {
it('returns false when the minimum version is not parseable', () => {
getCurrentVersionMock.mockReturnValue('1.2.3');
const selector = getMeetsMinimumVersionToShowOptInModal.resultFunc(
makeMinVersionFlags('invalid.min.version'),
);
expect(selector).toBeFalsy();
});

it('returns false when the platform version is not parseable', () => {
getCurrentVersionMock.mockReturnValue('invalid.version');
const selector = getMeetsMinimumVersionToShowOptInModal.resultFunc(
makeMinVersionFlags(minVersionMock),
);
expect(selector).toBeFalsy();
});

it('returns false when feature flags are missing', () => {
getCurrentVersionMock.mockReturnValue('1.2.3');
const featureFlags = null;
const selector =
getMeetsMinimumVersionToShowOptInModal.resultFunc(featureFlags);
expect(selector).toBeFalsy();
});
});
});
58 changes: 40 additions & 18 deletions shared/modules/selectors/smart-transactions.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import type { Hex } from '@metamask/utils';
import { createSelector } from 'reselect';
import {
getAllowedSmartTransactionsChainIds,
SKIP_STX_RPC_URL_CHECK_CHAIN_IDS,
} from '../../constants/smartTransactions';
import {
accountSupportsSmartTx,
getCurrentChainId,
getCurrentNetwork,
accountSupportsSmartTx,
} from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file.
import {
getAllowedSmartTransactionsChainIds,
SKIP_STX_RPC_URL_CHECK_CHAIN_IDS,
} from '../../constants/smartTransactions';
import { isProduction } from '../environment';
import { compareVersions, parseVersion } from '../../lib/semversion';
import ExtensionPlatform from '../../../app/scripts/platforms/extension';
import { getIsFeatureFlagLoaded } from '../../../ui/ducks/swaps/swaps';
import {
ChainSpecificFeatureFlags,
SmartTransactionsFeatureFlags,
getFeatureFlagsByChainId,
} from './feature-flags';

type SmartTransactionsMetaMaskState = {
metamask: {
Expand All @@ -32,19 +41,8 @@ type SmartTransactionsMetaMaskState = {
};
swapsState: {
swapsFeatureFlags: {
ethereum: {
extensionActive: boolean;
mobileActive: boolean;
smartTransactions: {
expectedDeadline?: number;
maxDeadline?: number;
returnTxHashAsap?: boolean;
};
};
smartTransactions: {
extensionActive: boolean;
mobileActive: boolean;
};
ethereum: ChainSpecificFeatureFlags;
smartTransactions: SmartTransactionsFeatureFlags;
};
};
smartTransactionsState: {
Expand Down Expand Up @@ -89,10 +87,34 @@ const getIsAllowedRpcUrlForSmartTransactions = (
return rpcUrl?.hostname?.endsWith('.infura.io');
};

/**
* Returns true if the current platform version is greater than or equal to the
* minimum version required to show the smart transaction opt-in modal.
*/
export const getMeetsMinimumVersionToShowOptInModal = createSelector(
getFeatureFlagsByChainId,
(featureFlags) => {
const minVersionStr = featureFlags?.smartTransactions?.optInModalMinVersion;
const minVer = parseVersion(minVersionStr);
if (!minVer) {
return false;
}
const version = parseVersion(
(global.platform as ExtensionPlatform).getVersion(),
);
if (!version) {
return false;
}
return compareVersions(version, minVer) >= 0;
},
);

export const getIsSmartTransactionsOptInModalAvailable = (
state: SmartTransactionsMetaMaskState,
) => {
return (
getIsFeatureFlagLoaded(state) &&
getMeetsMinimumVersionToShowOptInModal(state) &&
getCurrentChainSupportsSmartTransactions(state) &&
getIsAllowedRpcUrlForSmartTransactions(state) &&
getSmartTransactionsOptInStatus(state) === null
Expand Down
Loading