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 {