Skip to content

Commit e19710c

Browse files
authored
chore: Add Solana Devnet feature flag (#19662)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** We are adding the Solana testnet feature flag, in order to open the support for enabling the Devnet network for Solana. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.
1 parent 8159d39 commit e19710c

File tree

5 files changed

+264
-5
lines changed

5 files changed

+264
-5
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { selectIsSolanaTestnetEnabled } from '.';
2+
import mockedEngine from '../../../core/__mocks__/MockedEngine';
3+
import { mockedEmptyFlagsState, mockedUndefinedFlagsState } from '../mocks';
4+
5+
jest.mock('../../../core/Engine', () => ({
6+
init: () => mockedEngine.init(),
7+
}));
8+
9+
const originalEnv = process.env;
10+
beforeEach(() => {
11+
jest.resetModules();
12+
process.env = { ...originalEnv };
13+
});
14+
15+
afterEach(() => {
16+
process.env = originalEnv;
17+
jest.clearAllMocks();
18+
});
19+
20+
const mockedStateWithSolanaTestnetsEnabled = {
21+
engine: {
22+
backgroundState: {
23+
RemoteFeatureFlagController: {
24+
remoteFeatureFlags: {
25+
solanaTestnetsEnabled: true,
26+
},
27+
cacheTimestamp: 0,
28+
},
29+
},
30+
},
31+
};
32+
33+
const mockedStateWithSolanaTestnetsDisabled = {
34+
engine: {
35+
backgroundState: {
36+
RemoteFeatureFlagController: {
37+
remoteFeatureFlags: {
38+
solanaTestnetsEnabled: false,
39+
},
40+
cacheTimestamp: 0,
41+
},
42+
},
43+
},
44+
};
45+
46+
const mockedStateWithoutSolanaTestnetsFlag = {
47+
engine: {
48+
backgroundState: {
49+
RemoteFeatureFlagController: {
50+
remoteFeatureFlags: {
51+
someOtherFlag: true,
52+
},
53+
cacheTimestamp: 0,
54+
},
55+
},
56+
},
57+
};
58+
59+
describe('Solana Testnet Feature Flag Selector', () => {
60+
it('returns true when solanaTestnetsEnabled feature flag is enabled', () => {
61+
const result = selectIsSolanaTestnetEnabled(
62+
mockedStateWithSolanaTestnetsEnabled,
63+
);
64+
65+
expect(result).toBe(true);
66+
});
67+
68+
it('returns false when solanaTestnetsEnabled feature flag is explicitly disabled', () => {
69+
const result = selectIsSolanaTestnetEnabled(
70+
mockedStateWithSolanaTestnetsDisabled,
71+
);
72+
73+
expect(result).toBe(false);
74+
});
75+
76+
it('returns undefined when solanaTestnetsEnabled feature flag property is missing', () => {
77+
const result = selectIsSolanaTestnetEnabled(
78+
mockedStateWithoutSolanaTestnetsFlag,
79+
);
80+
81+
expect(result).toBeUndefined();
82+
});
83+
84+
it('returns undefined when feature flag state is empty', () => {
85+
const result = selectIsSolanaTestnetEnabled(mockedEmptyFlagsState);
86+
87+
expect(result).toBeUndefined();
88+
});
89+
90+
it('returns undefined when RemoteFeatureFlagController state is undefined', () => {
91+
const result = selectIsSolanaTestnetEnabled(mockedUndefinedFlagsState);
92+
93+
expect(result).toBeUndefined();
94+
});
95+
96+
it('handles null values correctly', () => {
97+
const stateWithNullFlag = {
98+
engine: {
99+
backgroundState: {
100+
RemoteFeatureFlagController: {
101+
remoteFeatureFlags: {
102+
solanaTestnetsEnabled: null,
103+
},
104+
cacheTimestamp: 0,
105+
},
106+
},
107+
},
108+
};
109+
110+
const result = selectIsSolanaTestnetEnabled(stateWithNullFlag);
111+
112+
expect(result).toBeNull();
113+
});
114+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createSelector } from 'reselect';
2+
import { selectRemoteFeatureFlags } from '..';
3+
4+
export const selectIsSolanaTestnetEnabled = createSelector(
5+
selectRemoteFeatureFlags,
6+
(remoteFeatureFlags) => remoteFeatureFlags.solanaTestnetsEnabled,
7+
);

app/selectors/multichain/multichain.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ function getNonEvmState(
149149
account?: InternalAccount,
150150
mockBtcRate?: string,
151151
showFiatOnTestnets: boolean = true,
152+
isSolanaTestnetEnabled: boolean = false,
152153
): RootState {
153154
const {
154155
MOCK_ACCOUNT_BIP122_P2WPKH: mockBtcAccount,
@@ -180,6 +181,11 @@ function getNonEvmState(
180181
isUnlocked: true,
181182
},
182183
},
184+
RemoteFeatureFlagController: {
185+
remoteFeatureFlags: {
186+
solanaTestnetsEnabled: isSolanaTestnetEnabled,
187+
},
188+
},
183189
AccountsController: {
184190
internalAccounts: {
185191
selectedAccount: selectedAccount.id,
@@ -673,6 +679,109 @@ describe('MultichainNonEvm Selectors', () => {
673679
transactions: [],
674680
});
675681
});
682+
683+
it('returns mainnet transactions normally', () => {
684+
const state = getNonEvmState(MOCK_SOLANA_ACCOUNT);
685+
686+
const mockTransactionData = {
687+
transactions: [
688+
{
689+
id: 'sol-tx-id',
690+
timestamp: 1733736433,
691+
chain: SolScope.Mainnet,
692+
status: 'confirmed' as const,
693+
type: 'send' as const,
694+
account: MOCK_SOLANA_ACCOUNT.id,
695+
from: [],
696+
to: [],
697+
fees: [],
698+
events: [],
699+
},
700+
],
701+
next: null,
702+
lastUpdated: Date.now(),
703+
};
704+
705+
state.engine.backgroundState.MultichainTransactionsController.nonEvmTransactions =
706+
{
707+
[MOCK_SOLANA_ACCOUNT.id]: {
708+
[SolScope.Mainnet]: mockTransactionData,
709+
},
710+
};
711+
712+
expect(selectNonEvmTransactions(state)).toEqual(mockTransactionData);
713+
});
714+
715+
it('blocks devnet transactions when feature flag is disabled', () => {
716+
const state = getNonEvmState(MOCK_SOLANA_ACCOUNT, undefined, true, false);
717+
state.engine.backgroundState.MultichainNetworkController.selectedMultichainNetworkChainId =
718+
SolScope.Devnet;
719+
720+
state.engine.backgroundState.MultichainTransactionsController.nonEvmTransactions =
721+
{
722+
[MOCK_SOLANA_ACCOUNT.id]: {
723+
[SolScope.Devnet]: {
724+
transactions: [
725+
{
726+
id: 'devnet-tx',
727+
timestamp: 1733736433,
728+
chain: SolScope.Devnet,
729+
status: 'confirmed' as const,
730+
type: 'send' as const,
731+
account: MOCK_SOLANA_ACCOUNT.id,
732+
from: [],
733+
to: [],
734+
fees: [],
735+
events: [],
736+
},
737+
],
738+
next: null,
739+
lastUpdated: Date.now(),
740+
},
741+
},
742+
};
743+
744+
// Returns empty state when devnet is selected but feature flag is disabled
745+
expect(selectNonEvmTransactions(state)).toEqual({
746+
lastUpdated: 0,
747+
next: null,
748+
transactions: [],
749+
});
750+
});
751+
752+
it('allows devnet transactions when feature flag is enabled', () => {
753+
const state = getNonEvmState(MOCK_SOLANA_ACCOUNT, undefined, true, true);
754+
state.engine.backgroundState.MultichainNetworkController.selectedMultichainNetworkChainId =
755+
SolScope.Devnet;
756+
757+
const mockDevnetData = {
758+
transactions: [
759+
{
760+
id: 'devnet-tx',
761+
timestamp: 1733736433,
762+
chain: SolScope.Devnet,
763+
status: 'confirmed' as const,
764+
type: 'send' as const,
765+
account: MOCK_SOLANA_ACCOUNT.id,
766+
from: [],
767+
to: [],
768+
fees: [],
769+
events: [],
770+
},
771+
],
772+
next: null,
773+
lastUpdated: Date.now(),
774+
};
775+
776+
state.engine.backgroundState.MultichainTransactionsController.nonEvmTransactions =
777+
{
778+
[MOCK_SOLANA_ACCOUNT.id]: {
779+
[SolScope.Devnet]: mockDevnetData,
780+
},
781+
};
782+
783+
expect(selectNonEvmTransactions(state)).toEqual(mockDevnetData);
784+
});
676785
});
677786

678787
describe('selectMultichainHistoricalPrices', () => {

app/selectors/multichain/multichain.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import {
1212
selectSelectedInternalAccount,
1313
} from '../accountsController';
1414
import { createDeepEqualSelector } from '../util';
15-
import { Balance } from '@metamask/keyring-api';
15+
import { Balance, SolScope } from '@metamask/keyring-api';
1616
import { selectConversionRate } from '../currencyRateController';
1717
import { isMainNet } from '../../util/networks';
1818
import { selectAccountBalanceByChainId } from '../accountTrackerController';
1919
import { selectShowFiatInTestnets } from '../settings';
20+
import { selectIsSolanaTestnetEnabled } from '../featureFlagController/solanaTestnet';
2021
import {
2122
selectIsEvmNetworkSelected,
2223
selectSelectedNonEvmNetworkChainId,
@@ -403,7 +404,13 @@ export const selectNonEvmTransactions = createDeepEqualSelector(
403404
selectMultichainTransactions,
404405
selectSelectedInternalAccount,
405406
selectSelectedNonEvmNetworkChainId,
406-
(nonEvmTransactions, selectedAccount, selectedNonEvmNetworkChainId) => {
407+
selectIsSolanaTestnetEnabled,
408+
(
409+
nonEvmTransactions,
410+
selectedAccount,
411+
selectedNonEvmNetworkChainId,
412+
isSolanaTestnetEnabled,
413+
) => {
407414
if (!selectedAccount) {
408415
return DEFAULT_TRANSACTION_STATE_ENTRY;
409416
}
@@ -413,6 +420,15 @@ export const selectNonEvmTransactions = createDeepEqualSelector(
413420
return DEFAULT_TRANSACTION_STATE_ENTRY;
414421
}
415422

423+
// If trying to access devnet transactions but feature flag is disabled, return the default transaction state entry
424+
if (
425+
selectedNonEvmNetworkChainId === SolScope.Devnet &&
426+
!isSolanaTestnetEnabled
427+
) {
428+
return DEFAULT_TRANSACTION_STATE_ENTRY;
429+
}
430+
431+
// For all other cases, return transactions for the selected chain
416432
return (
417433
accountTransactions[selectedNonEvmNetworkChainId] ??
418434
DEFAULT_TRANSACTION_STATE_ENTRY

app/selectors/multichainNetworkController/index.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import {
77
type MultichainNetworkConfiguration,
88
} from '@metamask/multichain-network-controller';
99
import { toHex } from '@metamask/controller-utils';
10-
import { CaipChainId } from '@metamask/utils';
10+
import { CaipChainId, Json } from '@metamask/utils';
1111
import { BtcScope, SolScope, EthScope } from '@metamask/keyring-api';
1212
import { RootState } from '../../reducers';
1313
import imageIcons from '../../images/image-icons';
1414
import { createDeepEqualSelector } from '../util';
15+
import { selectIsSolanaTestnetEnabled } from '../featureFlagController/solanaTestnet';
1516

1617
export const selectMultichainNetworkControllerState = (state: RootState) =>
1718
state.engine.backgroundState?.MultichainNetworkController;
@@ -36,8 +37,12 @@ export const selectSelectedNonEvmNetworkChainId = createDeepEqualSelector(
3637
* @returns An object where the keys are chain IDs and the values are network configurations.
3738
*/
3839
export const selectNonEvmNetworkConfigurationsByChainId = createSelector(
39-
selectMultichainNetworkControllerState,
40-
(multichainNetworkControllerState: MultichainNetworkControllerState) => {
40+
[selectMultichainNetworkControllerState, selectIsSolanaTestnetEnabled],
41+
(
42+
multichainNetworkControllerState: MultichainNetworkControllerState,
43+
isSolanaTestnetEnabled: Json,
44+
) => {
45+
const isSolanaTestnetEnabledBoolean = Boolean(isSolanaTestnetEnabled);
4146
const extendedNonEvmData: Record<
4247
CaipChainId,
4348
{
@@ -54,6 +59,13 @@ export const selectNonEvmNetworkConfigurationsByChainId = createSelector(
5459
ticker: MULTICHAIN_NETWORK_TICKER[SolScope.Mainnet],
5560
isTestnet: false,
5661
},
62+
[SolScope.Devnet]: {
63+
decimals: MULTICHAIN_NETWORK_DECIMAL_PLACES[SolScope.Devnet],
64+
imageSource: imageIcons.SOLANA,
65+
ticker: MULTICHAIN_NETWORK_TICKER[SolScope.Devnet],
66+
isTestnet: true,
67+
name: 'Solana Devnet',
68+
},
5769
[BtcScope.Mainnet]: {
5870
decimals: MULTICHAIN_NETWORK_DECIMAL_PLACES[BtcScope.Mainnet],
5971
imageSource: imageIcons.BTC,
@@ -98,6 +110,7 @@ export const selectNonEvmNetworkConfigurationsByChainId = createSelector(
98110
BtcScope.Signet,
99111
///: END:ONLY_INCLUDE_IF
100112
SolScope.Mainnet,
113+
...(isSolanaTestnetEnabledBoolean ? [SolScope.Devnet] : []),
101114
];
102115

103116
const nonEvmNetworks: Record<CaipChainId, MultichainNetworkConfiguration> =

0 commit comments

Comments
 (0)