diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 8683dc1bb47d..7081e0c95c31 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -283,6 +283,9 @@ "addNewBitcoinAccount": { "message": "Add a new Bitcoin account (Beta)" }, + "addNewBitcoinTestnetAccount": { + "message": "Add a new Bitcoin account (Testnet)" + }, "addNewToken": { "message": "Add new token" }, @@ -768,6 +771,12 @@ "bitcoinSupportToggleTitle": { "message": "Enable \"Add a new Bitcoin account (Beta)\"" }, + "bitcoinTestnetSupportToggleDescription": { + "message": "Turning on this feature will give you the option to add a Bitcoin Account for the test network." + }, + "bitcoinTestnetSupportToggleTitle": { + "message": "Enable \"Add a new Bitcoin account (Testnet)\"" + }, "blockExplorerAccountAction": { "message": "Account", "description": "This is used with viewOnEtherscan and viewInExplorer e.g View Account in Explorer" diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index aa14bc84a1a4..e4de5521d3ad 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -62,6 +62,7 @@ export default class PreferencesController { openSeaEnabled: true, // todo set this to true securityAlertsEnabled: true, bitcoinSupportEnabled: false, + bitcoinTestnetSupportEnabled: false, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) addSnapAccountEnabled: false, ///: END:ONLY_INCLUDE_IF @@ -295,7 +296,7 @@ export default class PreferencesController { * Setter for the `bitcoinSupportEnabled` property. * * @param {boolean} bitcoinSupportEnabled - Whether or not the user wants to - * enable the "Add a new Bitcoin account" button. + * enable the "Add a new Bitcoin account (Beta)" button. */ setBitcoinSupportEnabled(bitcoinSupportEnabled) { this.store.updateState({ @@ -303,6 +304,18 @@ export default class PreferencesController { }); } + /** + * Setter for the `bitcoinTestnetSupportEnabled` property. + * + * @param {boolean} bitcoinTestnetSupportEnabled - Whether or not the user wants to + * enable the "Add a new Bitcoin account (Testnet)" button. + */ + setBitcoinTestnetSupportEnabled(bitcoinTestnetSupportEnabled) { + this.store.updateState({ + bitcoinTestnetSupportEnabled, + }); + } + /** * Setter for the `useExternalNameSources` property * diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index 2d0df8f78994..b9687cd2cad5 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -425,6 +425,7 @@ export const SENTRY_UI_STATE = { confirmationExchangeRates: true, useSafeChainsListValidation: true, bitcoinSupportEnabled: false, + bitcoinTestnetSupportEnabled: false, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) addSnapAccountEnabled: false, snapsAddSnapAccountModalDismissed: false, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e24d140b5575..a12932bffa9b 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -3021,6 +3021,10 @@ export default class MetamaskController extends EventEmitter { preferencesController.setBitcoinSupportEnabled.bind( preferencesController, ), + setBitcoinTestnetSupportEnabled: + preferencesController.setBitcoinTestnetSupportEnabled.bind( + preferencesController, + ), setUseExternalNameSources: preferencesController.setUseExternalNameSources.bind( preferencesController, diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 7a83ec4f61e2..9f8de615691f 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -513,6 +513,7 @@ export enum MetaMetricsEventName { AppWindowExpanded = 'App Window Expanded', BridgeLinkClicked = 'Bridge Link Clicked', BitcoinSupportToggled = 'Bitcoin Support Toggled', + BitcoinTestnetSupportToggled = 'Bitcoin Testnet Support Toggled', DappViewed = 'Dapp Viewed', DecryptionApproved = 'Decryption Approved', DecryptionRejected = 'Decryption Rejected', diff --git a/test/data/mock-state.json b/test/data/mock-state.json index 6ec7a271f2c1..ca7bccddb53f 100644 --- a/test/data/mock-state.json +++ b/test/data/mock-state.json @@ -1881,6 +1881,7 @@ ], "addSnapAccountEnabled": false, "bitcoinSupportEnabled": false, + "bitcoinTestnetSupportEnabled": false, "pendingApprovals": { "testApprovalId": { "id": "testApprovalId", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index eb815b7727eb..3bf1a277a21f 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -185,6 +185,7 @@ "openSeaEnabled": false, "securityAlertsEnabled": "boolean", "bitcoinSupportEnabled": "boolean", + "bitcoinTestnetSupportEnabled": "boolean", "addSnapAccountEnabled": "boolean", "advancedGasFee": {}, "featureFlags": {}, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 5f9666cb6702..9a827b723392 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -123,6 +123,7 @@ "securityAlertsEnabled": "boolean", "addSnapAccountEnabled": "boolean", "bitcoinSupportEnabled": "boolean", + "bitcoinTestnetSupportEnabled": "boolean", "advancedGasFee": {}, "incomingTransactionsPreferences": {}, "identities": "object", diff --git a/ui/components/multichain/account-list-menu/account-list-menu.js b/ui/components/multichain/account-list-menu/account-list-menu.js index 6e4c7490541c..6e27dd7a6187 100644 --- a/ui/components/multichain/account-list-menu/account-list-menu.js +++ b/ui/components/multichain/account-list-menu/account-list-menu.js @@ -48,6 +48,7 @@ import { ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(build-flask) getIsBitcoinSupportEnabled, + getIsBitcoinTestnetSupportEnabled, ///: END:ONLY_INCLUDE_IF } from '../../../selectors'; import { setSelectedAccount } from '../../../store/actions'; @@ -66,7 +67,11 @@ import { getEnvironmentType } from '../../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app'; import { getAccountLabel } from '../../../helpers/utils/accounts'; ///: BEGIN:ONLY_INCLUDE_IF(build-flask) -import { hasCreatedBtcMainnetAccount } from '../../../selectors/accounts'; +import { + hasCreatedBtcMainnetAccount, + hasCreatedBtcTestnetAccount, +} from '../../../selectors/accounts'; +import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; ///: END:ONLY_INCLUDE_IF import { HiddenAccountList } from './hidden-account-list'; @@ -80,6 +85,8 @@ const ACTION_MODES = { ///: BEGIN:ONLY_INCLUDE_IF(build-flask) // Displays the add account form controls (for bitcoin account) ADD_BITCOIN: 'add-bitcoin', + // Same but for testnet + ADD_BITCOIN_TESTNET: 'add-bitcoin-testnet', ///: END:ONLY_INCLUDE_IF // Displays the import account form controls IMPORT: 'import', @@ -99,6 +106,8 @@ export const getActionTitle = (t, actionMode) => { ///: BEGIN:ONLY_INCLUDE_IF(build-flask) case ACTION_MODES.ADD_BITCOIN: return t('addAccount'); + case ACTION_MODES.ADD_BITCOIN_TESTNET: + return t('addAccount'); ///: END:ONLY_INCLUDE_IF case ACTION_MODES.MENU: return t('addAccount'); @@ -156,9 +165,15 @@ export const AccountListMenu = ({ ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(build-flask) const bitcoinSupportEnabled = useSelector(getIsBitcoinSupportEnabled); + const bitcoinTestnetSupportEnabled = useSelector( + getIsBitcoinTestnetSupportEnabled, + ); const isBtcMainnetAccountAlreadyCreated = useSelector( hasCreatedBtcMainnetAccount, ); + const isBtcTestnetAccountAlreadyCreated = useSelector( + hasCreatedBtcTestnetAccount, + ); ///: END:ONLY_INCLUDE_IF const [searchQuery, setSearchQuery] = useState(''); @@ -219,10 +234,34 @@ export const AccountListMenu = ({ ) : null} { + // Bitcoin mainnet: ///: BEGIN:ONLY_INCLUDE_IF(build-flask) bitcoinSupportEnabled && actionMode === ACTION_MODES.ADD_BITCOIN ? ( { + if (confirmed) { + onClose(); + } else { + setActionMode(ACTION_MODES.LIST); + } + }} + /> + + ) : null + ///: END:ONLY_INCLUDE_IF + } + { + // Bitcoin testnet: + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + bitcoinTestnetSupportEnabled && + actionMode === ACTION_MODES.ADD_BITCOIN_TESTNET ? ( + + { if (confirmed) { onClose(); @@ -303,6 +342,25 @@ export const AccountListMenu = ({ ) : null ///: END:ONLY_INCLUDE_IF } + { + ///: BEGIN:ONLY_INCLUDE_IF(build-flask) + bitcoinTestnetSupportEnabled ? ( + + { + setActionMode(ACTION_MODES.ADD_BITCOIN_TESTNET); + }} + data-testid="multichain-account-menu-popover-add-account-testnet" + > + {t('addNewBitcoinTestnetAccount')} + + + ) : null + ///: END:ONLY_INCLUDE_IF + } ; diff --git a/ui/components/multichain/create-btc-account/create-btc-account.test.tsx b/ui/components/multichain/create-btc-account/create-btc-account.test.tsx index b667e715c215..23fbe9226707 100644 --- a/ui/components/multichain/create-btc-account/create-btc-account.test.tsx +++ b/ui/components/multichain/create-btc-account/create-btc-account.test.tsx @@ -10,7 +10,14 @@ import { CreateBtcAccount } from '.'; const render = (props = { onActionComplete: jest.fn() }) => { const store = configureStore(mockState); - return renderWithProvider(, store); + return renderWithProvider( + , + store, + ); }; const ACCOUNT_NAME = 'Bitcoin Account'; diff --git a/ui/components/multichain/create-btc-account/create-btc-account.tsx b/ui/components/multichain/create-btc-account/create-btc-account.tsx index 40c8bdb169e7..29c7b8f345b3 100644 --- a/ui/components/multichain/create-btc-account/create-btc-account.tsx +++ b/ui/components/multichain/create-btc-account/create-btc-account.tsx @@ -14,10 +14,20 @@ type CreateBtcAccountOptions = { * Callback called once the account has been created */ onActionComplete: (completed: boolean) => Promise; + /** + * CAIP-2 chain ID + */ + network: MultichainNetworks; + /** + * Default account name + */ + defaultAccountName: string; }; export const CreateBtcAccount = ({ onActionComplete, + defaultAccountName, + network, }: CreateBtcAccountOptions) => { const dispatch = useDispatch(); @@ -25,7 +35,7 @@ export const CreateBtcAccount = ({ // Trigger the Snap account creation flow const client = new KeyringClient(new BitcoinWalletSnapSender()); const account = await client.createAccount({ - scope: MultichainNetworks.BITCOIN, + scope: network, }); // TODO: Use the new Snap account creation flow that also include account renaming @@ -45,7 +55,7 @@ export const CreateBtcAccount = ({ }; const getNextAvailableAccountName = async (_accounts: InternalAccount[]) => { - return 'Bitcoin Account'; + return defaultAccountName; }; return ( diff --git a/ui/pages/settings/experimental-tab/experimental-tab.component.tsx b/ui/pages/settings/experimental-tab/experimental-tab.component.tsx index 04f4de68f608..ef03a23c09b8 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.component.tsx +++ b/ui/pages/settings/experimental-tab/experimental-tab.component.tsx @@ -32,6 +32,8 @@ import { type ExperimentalTabProps = { bitcoinSupportEnabled: boolean; setBitcoinSupportEnabled: (value: boolean) => void; + bitcoinTestnetSupportEnabled: boolean; + setBitcoinTestnetSupportEnabled: (value: boolean) => void; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) addSnapAccountEnabled: boolean; setAddSnapAccountEnabled: (value: boolean) => void; @@ -241,7 +243,12 @@ export default class ExperimentalTab extends PureComponent // we should remove it for the feature release renderBitcoinSupport() { const { t, trackEvent } = this.context; - const { bitcoinSupportEnabled, setBitcoinSupportEnabled } = this.props; + const { + bitcoinSupportEnabled, + setBitcoinSupportEnabled, + bitcoinTestnetSupportEnabled, + setBitcoinTestnetSupportEnabled, + } = this.props; return ( <> @@ -272,6 +279,24 @@ export default class ExperimentalTab extends PureComponent toggleOffLabel: t('off'), toggleOnLabel: t('on'), })} + {this.renderToggleSection({ + title: t('bitcoinTestnetSupportToggleTitle'), + description: t('bitcoinTestnetSupportToggleDescription'), + toggleValue: bitcoinTestnetSupportEnabled, + toggleCallback: (value) => { + trackEvent({ + event: MetaMetricsEventName.BitcoinTestnetSupportToggled, + category: MetaMetricsEventCategory.Settings, + properties: { + enabled: !value, + }, + }); + setBitcoinTestnetSupportEnabled(!value); + }, + toggleDataTestId: 'bitcoin-testnet-accounts-toggle', + toggleOffLabel: t('off'), + toggleOnLabel: t('on'), + })} ); } diff --git a/ui/pages/settings/experimental-tab/experimental-tab.container.ts b/ui/pages/settings/experimental-tab/experimental-tab.container.ts index 7137d1665692..ae2c34f46f7e 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.container.ts +++ b/ui/pages/settings/experimental-tab/experimental-tab.container.ts @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import { setBitcoinSupportEnabled, + setBitcoinTestnetSupportEnabled, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) setAddSnapAccountEnabled, ///: END:ONLY_INCLUDE_IF @@ -13,6 +14,7 @@ import { } from '../../../store/actions'; import { getIsBitcoinSupportEnabled, + getIsBitcoinTestnetSupportEnabled, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) getIsAddSnapAccountEnabled, ///: END:ONLY_INCLUDE_IF @@ -32,6 +34,7 @@ const mapStateToProps = (state: MetaMaskReduxState) => { const featureNotificationsEnabled = getFeatureNotificationsEnabled(state); return { bitcoinSupportEnabled: getIsBitcoinSupportEnabled(state), + bitcoinTestnetSupportEnabled: getIsBitcoinTestnetSupportEnabled(state), ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) addSnapAccountEnabled: getIsAddSnapAccountEnabled(state), ///: END:ONLY_INCLUDE_IF @@ -46,6 +49,8 @@ const mapDispatchToProps = (dispatch: MetaMaskReduxDispatch) => { return { setBitcoinSupportEnabled: (value: boolean) => setBitcoinSupportEnabled(value), + setBitcoinTestnetSupportEnabled: (value: boolean) => + setBitcoinTestnetSupportEnabled(value), ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) setAddSnapAccountEnabled: (value: boolean) => setAddSnapAccountEnabled(value), diff --git a/ui/pages/settings/experimental-tab/experimental-tab.test.js b/ui/pages/settings/experimental-tab/experimental-tab.test.js index ef519bca6df2..3abefb3a58df 100644 --- a/ui/pages/settings/experimental-tab/experimental-tab.test.js +++ b/ui/pages/settings/experimental-tab/experimental-tab.test.js @@ -30,7 +30,7 @@ describe('ExperimentalTab', () => { const { getAllByRole } = render(); const toggle = getAllByRole('checkbox'); - expect(toggle).toHaveLength(5); + expect(toggle).toHaveLength(6); }); it('enables add account snap', async () => { diff --git a/ui/selectors/accounts.test.ts b/ui/selectors/accounts.test.ts index 911cc0f48c1e..7b25234ab783 100644 --- a/ui/selectors/accounts.test.ts +++ b/ui/selectors/accounts.test.ts @@ -3,12 +3,14 @@ import { MOCK_ACCOUNT_EOA, MOCK_ACCOUNT_ERC4337, MOCK_ACCOUNT_BIP122_P2WPKH, + MOCK_ACCOUNT_BIP122_P2WPKH_TESTNET, } from '../../test/data/mock-accounts'; import { AccountsState, isSelectedInternalAccountEth, isSelectedInternalAccountBtc, hasCreatedBtcMainnetAccount, + hasCreatedBtcTestnetAccount, } from './accounts'; const MOCK_STATE: AccountsState = { @@ -107,4 +109,39 @@ describe('Accounts Selectors', () => { expect(isSelectedInternalAccountBtc(state)).toBe(false); }); }); + + describe('hasCreatedBtcTestnetAccount', () => { + it('returns true if the BTC testnet account has been created', () => { + const state: AccountsState = { + metamask: { + // No-op for this test, but might be required in the future: + ...MOCK_STATE.metamask, + internalAccounts: { + selectedAccount: MOCK_ACCOUNT_BIP122_P2WPKH.id, + accounts: [ + MOCK_ACCOUNT_BIP122_P2WPKH, + MOCK_ACCOUNT_BIP122_P2WPKH_TESTNET, + ], + }, + }, + }; + + expect(hasCreatedBtcTestnetAccount(state)).toBe(true); + }); + + it('returns false if the BTC testnet account has not been created yet', () => { + const state: AccountsState = { + metamask: { + // No-op for this test, but might be required in the future: + ...MOCK_STATE.metamask, + internalAccounts: { + selectedAccount: MOCK_ACCOUNT_BIP122_P2WPKH.id, + accounts: [MOCK_ACCOUNT_BIP122_P2WPKH], + }, + }, + }; + + expect(isSelectedInternalAccountBtc(state)).toBe(false); + }); + }); }); diff --git a/ui/selectors/accounts.ts b/ui/selectors/accounts.ts index 9500f4e5ff3d..bd33d5af1f89 100644 --- a/ui/selectors/accounts.ts +++ b/ui/selectors/accounts.ts @@ -4,7 +4,10 @@ import { InternalAccount, } from '@metamask/keyring-api'; import { AccountsControllerState } from '@metamask/accounts-controller'; -import { isBtcMainnetAddress } from '../../shared/lib/multichain'; +import { + isBtcMainnetAddress, + isBtcTestnetAddress, +} from '../../shared/lib/multichain'; import { getSelectedInternalAccount, getInternalAccounts } from './selectors'; export type AccountsState = { @@ -28,11 +31,20 @@ export function isSelectedInternalAccountBtc(state: AccountsState) { return isBtcAccount(getSelectedInternalAccount(state)); } -export function hasCreatedBtcMainnetAccount(state: AccountsState) { +function hasCreatedBtcAccount( + state: AccountsState, + isAddressCallback: (address: string) => boolean, +) { const accounts = getInternalAccounts(state); return accounts.some((account) => { - // Since we might wanna support testnet accounts later, we do - // want to make this one very explicit and check for mainnet addresses! - return isBtcAccount(account) && isBtcMainnetAddress(account.address); + return isBtcAccount(account) && isAddressCallback(account.address); }); } + +export function hasCreatedBtcMainnetAccount(state: AccountsState) { + return hasCreatedBtcAccount(state, isBtcMainnetAddress); +} + +export function hasCreatedBtcTestnetAccount(state: AccountsState) { + return hasCreatedBtcAccount(state, isBtcTestnetAddress); +} diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index f28d0507265b..36c14b4f77cd 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -2303,6 +2303,16 @@ export function getIsBitcoinSupportEnabled(state) { return state.metamask.bitcoinSupportEnabled; } +/** + * Get the state of the `bitcoinTestnetSupportEnabled` flag. + * + * @param {*} state + * @returns The state of the `bitcoinTestnetSupportEnabled` flag. + */ +export function getIsBitcoinTestnetSupportEnabled(state) { + return state.metamask.bitcoinTestnetSupportEnabled; +} + export function getIsCustomNetwork(state) { const chainId = getCurrentChainId(state); diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 4997a6ff8819..7493ee03ba3f 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -4971,6 +4971,14 @@ export async function setBitcoinSupportEnabled(value: boolean) { } } +export async function setBitcoinTestnetSupportEnabled(value: boolean) { + try { + await submitRequestToBackground('setBitcoinTestnetSupportEnabled', [value]); + } catch (error) { + logErrorWithMessage(error); + } +} + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) export async function setAddSnapAccountEnabled(value: boolean): Promise { try {