diff --git a/app/_locales/de/messages.json b/app/_locales/de/messages.json index f62b15c08c93..e8de6564304b 100644 --- a/app/_locales/de/messages.json +++ b/app/_locales/de/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Ein neues Bitcoin-Konto hinzufügen (Testnet)" }, + "addNewSolanaAccount": { + "message": "Ein neues Solana-Konto hinzufügen (Beta)" + }, "addNewToken": { "message": "Neues Token hinzufügen" }, diff --git a/app/_locales/el/messages.json b/app/_locales/el/messages.json index 3bede7c85edb..5d2ba61516fa 100644 --- a/app/_locales/el/messages.json +++ b/app/_locales/el/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Προσθήκη νέου λογαριασμού Bitcoin (Testnet)" }, + "addNewSolanaAccount": { + "message": "Προσθήκη νέου λογαριασμού Solana (Beta)" + }, "addNewToken": { "message": "Προσθήκη νέου token" }, diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 56e3614e3f39..610ddba3a4dd 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -287,6 +287,9 @@ "addNewBitcoinTestnetAccount": { "message": "Add a new Bitcoin account (Testnet)" }, + "addNewSolanaAccount": { + "message": "Add a new Solana account (Beta)" + }, "addNewToken": { "message": "Add new token" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 9ee7771947ba..92ad7c646a36 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -289,6 +289,9 @@ "addNewBitcoinTestnetAccount": { "message": "Add a new Bitcoin account (Testnet)" }, + "addNewSolanaAccount": { + "message": "Add a new Solana account (Beta)" + }, "addNewToken": { "message": "Add new token" }, diff --git a/app/_locales/es/messages.json b/app/_locales/es/messages.json index f13313976e74..604396f2c6eb 100644 --- a/app/_locales/es/messages.json +++ b/app/_locales/es/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Añadir una nueva cuenta de Bitcoin (Testnet)" }, + "addNewSolanaAccount": { + "message": "Añadir una nueva cuenta de Solana (Beta)" + }, "addNewToken": { "message": "Agregar nuevo token" }, diff --git a/app/_locales/fr/messages.json b/app/_locales/fr/messages.json index c785d2e0a00a..7c4db83af9e8 100644 --- a/app/_locales/fr/messages.json +++ b/app/_locales/fr/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Ajouter un nouveau compte Bitcoin (Testnet)" }, + "addNewSolanaAccount": { + "message": "Ajouter un nouveau compte Solana (Bêta)" + }, "addNewToken": { "message": "Ajouter un nouveau jeton" }, diff --git a/app/_locales/hi/messages.json b/app/_locales/hi/messages.json index 554bb2e91b84..9f3800ef6b61 100644 --- a/app/_locales/hi/messages.json +++ b/app/_locales/hi/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "एक नया Bitcoin अकाउंट जोड़ें (टैस्टनेट (testnet))" }, + "addNewSolanaAccount": { + "message": "एक नया Solana अकाउंट जोड़ें (बीटा)" + }, "addNewToken": { "message": "नया टोकन जोड़ें" }, diff --git a/app/_locales/id/messages.json b/app/_locales/id/messages.json index 8e60e6fe5a50..b070fb14cda9 100644 --- a/app/_locales/id/messages.json +++ b/app/_locales/id/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Tambahkan akun Bitcoin baru (Testnet)" }, + "addNewSolanaAccount": { + "message": "Tambahkan akun Solana baru (Beta)" + }, "addNewToken": { "message": "Tambahkan token baru" }, diff --git a/app/_locales/ja/messages.json b/app/_locales/ja/messages.json index 69824ae33b52..ad77733372b6 100644 --- a/app/_locales/ja/messages.json +++ b/app/_locales/ja/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "新しいビットコインアカウントの追加 (テストネット)" }, + "addNewSolanaAccount": { + "message": "新しいSolanaアカウントの追加 (ベータ版)" + }, "addNewToken": { "message": "新しいトークンを追加" }, diff --git a/app/_locales/ko/messages.json b/app/_locales/ko/messages.json index f160b3dccbc2..86077780926a 100644 --- a/app/_locales/ko/messages.json +++ b/app/_locales/ko/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "새 비트코인 계정 추가(테스트넷)" }, + "addNewSolanaAccount": { + "message": "새 솔라나 계정 추가(베타)" + }, "addNewToken": { "message": "신규 토큰 추가" }, diff --git a/app/_locales/pt/messages.json b/app/_locales/pt/messages.json index 589a58e94907..e868c22a817e 100644 --- a/app/_locales/pt/messages.json +++ b/app/_locales/pt/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Adicionar uma nova conta Bitcoin (Testnet)" }, + "addNewSolanaAccount": { + "message": "Adicionar uma nova conta Solana (Beta)" + }, "addNewToken": { "message": "Adicionar novo token" }, diff --git a/app/_locales/ru/messages.json b/app/_locales/ru/messages.json index 9462d9c6eb4a..f995ce06a662 100644 --- a/app/_locales/ru/messages.json +++ b/app/_locales/ru/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Добавить новый счет в биткойнах (тестнет)" }, + "addNewSolanaAccount": { + "message": "Добавить новый счет в Solana (бета-версия)" + }, "addNewToken": { "message": "Добавить новый токен" }, diff --git a/app/_locales/tl/messages.json b/app/_locales/tl/messages.json index 41612a6ce177..f49c14518dc3 100644 --- a/app/_locales/tl/messages.json +++ b/app/_locales/tl/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Magdagdag ng bagong account sa Bitcoin (Testnet)" }, + "addNewSolanaAccount": { + "message": "Magdagdag ng bagong account sa Solana (Beta)" + }, "addNewToken": { "message": "Magdagdag ng bagong token" }, diff --git a/app/_locales/tr/messages.json b/app/_locales/tr/messages.json index f11cc0d17523..7af54fd3eedd 100644 --- a/app/_locales/tr/messages.json +++ b/app/_locales/tr/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Yeni bir Bitcoin hesabı ekle (Test Ağı)" }, + "addNewSolanaAccount": { + "message": "Yeni bir Solana hesabı ekle (Beta)" + }, "addNewToken": { "message": "Yeni token ekleyin" }, diff --git a/app/_locales/vi/messages.json b/app/_locales/vi/messages.json index dd518df1bf22..700d73f11649 100644 --- a/app/_locales/vi/messages.json +++ b/app/_locales/vi/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "Thêm tài khoản Bitcoin mới (Mạng thử nghiệm)" }, + "addNewSolanaAccount": { + "message": "Thêm tài khoản Solana mới (Beta)" + }, "addNewToken": { "message": "Thêm token mới" }, diff --git a/app/_locales/zh_CN/messages.json b/app/_locales/zh_CN/messages.json index 818f1ffdb82b..a24b993874a0 100644 --- a/app/_locales/zh_CN/messages.json +++ b/app/_locales/zh_CN/messages.json @@ -256,6 +256,9 @@ "addNewBitcoinTestnetAccount": { "message": "添加新的比特币账户(测试网)" }, + "addNewSolanaAccount": { + "message": "添加新的Solana账户(测试版)" + }, "addNewToken": { "message": "添加新代币" }, diff --git a/test/e2e/constants.ts b/test/e2e/constants.ts index 8bf39d261bcb..a1388b93a512 100644 --- a/test/e2e/constants.ts +++ b/test/e2e/constants.ts @@ -50,3 +50,10 @@ export const DEFAULT_BTC_ACCOUNT = 'bc1qg6whd6pc0cguh6gpp3ewujm53hv32ta9hdp252'; /* Default (mocked) BTC balance used by the Bitcoin RPC provider */ export const DEFAULT_BTC_BALANCE = 1; // BTC + +/* Default (mocked) SOLANA address created using test SRP */ +export const DEFAULT_SOLANA_ACCOUNT = + 'E6Aa9DDv7zsePJHosoqiNb3cFuup3fkXTyRH2pZ1nVzP'; + +/* Default (mocked) SOLANA balance used by the Solana RPC provider */ +export const DEFAULT_SOLANA_BALANCE = 1; // SOL diff --git a/ui/components/multichain/account-list-menu/account-list-menu.tsx b/ui/components/multichain/account-list-menu/account-list-menu.tsx index cfb49d246ca6..75d04e9e60e0 100644 --- a/ui/components/multichain/account-list-menu/account-list-menu.tsx +++ b/ui/components/multichain/account-list-menu/account-list-menu.tsx @@ -68,6 +68,9 @@ import { getOriginOfCurrentTab, getSelectedInternalAccount, getUpdatedAndSortedAccounts, + ///: BEGIN:ONLY_INCLUDE_IF(solana) + getIsSolanaSupportEnabled, + ///: END:ONLY_INCLUDE_IF } from '../../../selectors'; import { setSelectedAccount } from '../../../store/actions'; import { @@ -97,8 +100,14 @@ import { hasCreatedBtcMainnetAccount, hasCreatedBtcTestnetAccount, } from '../../../selectors/accounts'; +///: END:ONLY_INCLUDE_IF + +///: BEGIN:ONLY_INCLUDE_IF(build-flask,solana) import { MultichainNetworks } from '../../../../shared/constants/multichain/networks'; -import { useBitcoinWalletSnapClient } from '../../../hooks/accounts/useBitcoinWalletSnapClient'; +import { + WalletClientType, + useMultichainWalletSnapClient, +} from '../../../hooks/accounts/useMultichainWalletSnapClient'; ///: END:ONLY_INCLUDE_IF import { InternalAccountWithBalance, @@ -106,6 +115,12 @@ import { MergedInternalAccount, } from '../../../selectors/selectors.types'; import { endTrace, TraceName } from '../../../../shared/lib/trace'; +///: BEGIN:ONLY_INCLUDE_IF(solana) +import { + SOLANA_WALLET_NAME, + SOLANA_WALLET_SNAP_ID, +} from '../../../../shared/lib/accounts/solana-wallet-snap'; +///: END:ONLY_INCLUDE_IF import { HiddenAccountList } from './hidden-account-list'; const ACTION_MODES = { @@ -269,7 +284,16 @@ export const AccountListMenu = ({ hasCreatedBtcTestnetAccount, ); - const bitcoinWalletSnapClient = useBitcoinWalletSnapClient(); + const bitcoinWalletSnapClient = useMultichainWalletSnapClient( + WalletClientType.Bitcoin, + ); + ///: END:ONLY_INCLUDE_IF + + ///: BEGIN:ONLY_INCLUDE_IF(solana) + const solanaSupportEnabled = useSelector(getIsSolanaSupportEnabled); + const solanaWalletSnapClient = useMultichainWalletSnapClient( + WalletClientType.Solana, + ); ///: END:ONLY_INCLUDE_IF const [searchQuery, setSearchQuery] = useState(''); @@ -453,6 +477,42 @@ export const AccountListMenu = ({ ) : null ///: END:ONLY_INCLUDE_IF } + { + ///: BEGIN:ONLY_INCLUDE_IF(solana) + solanaSupportEnabled && ( + + { + trackEvent({ + category: MetaMetricsEventCategory.Navigation, + event: MetaMetricsEventName.AccountAddSelected, + properties: { + account_type: MetaMetricsEventAccountType.Snap, + snap_id: SOLANA_WALLET_SNAP_ID, + snap_name: SOLANA_WALLET_NAME, + location: 'Main Menu', + }, + }); + + // The account creation + renaming is handled by the + // Snap account bridge, so we need to close the current + // modal + onClose(); + + await solanaWalletSnapClient.createAccount( + MultichainNetworks.SOLANA, + ); + }} + data-testid="multichain-account-menu-popover-add-solana-account" + > + {t('addNewSolanaAccount')} + + + ) + ///: END:ONLY_INCLUDE_IF + } ({ - handleSnapRequest: jest.fn(), - multichainUpdateBalance: jest.fn(), -})); - -const mockHandleSnapRequest = handleSnapRequest as jest.Mock; -const mockMultichainUpdateBalance = multichainUpdateBalance as jest.Mock; - -describe('useBitcoinWalletSnapClient', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const mockAccount = { - address: 'tb1q2hjrlnf8kmtt5dj6e49gqzy6jnpe0sj7ty50cl', - id: '11a33c6b-0d46-43f4-a401-01587d575fd0', - options: {}, - methods: [BtcMethod.SendMany], - type: BtcAccountType.P2wpkh, - }; - - it('dispatch a Snap keyring request to create a Bitcoin account', async () => { - const { result } = renderHook(() => useBitcoinWalletSnapClient()); - const bitcoinWalletSnapClient = result.current; - - mockHandleSnapRequest.mockResolvedValue(mockAccount); - - await bitcoinWalletSnapClient.createAccount(MultichainNetworks.BITCOIN); - expect(mockHandleSnapRequest).toHaveBeenCalledWith({ - origin: 'metamask', - snapId: BITCOIN_WALLET_SNAP_ID, - handler: HandlerType.OnKeyringRequest, - request: expect.any(Object), - }); - }); - - it('force fetches the balance after creating a Bitcoin account', async () => { - const { result } = renderHook(() => useBitcoinWalletSnapClient()); - const bitcoinWalletSnapClient = result.current; - - mockHandleSnapRequest.mockResolvedValue(mockAccount); - - await bitcoinWalletSnapClient.createAccount(MultichainNetworks.BITCOIN); - expect(mockMultichainUpdateBalance).toHaveBeenCalledWith(mockAccount.id); - }); -}); diff --git a/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts b/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts new file mode 100644 index 000000000000..d177986fee44 --- /dev/null +++ b/ui/hooks/accounts/useMultichainWalletSnapClient.test.ts @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { HandlerType } from '@metamask/snaps-utils'; +import { BtcAccountType, BtcMethod } from '@metamask/keyring-api'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; +import { BITCOIN_WALLET_SNAP_ID } from '../../../shared/lib/accounts/bitcoin-wallet-snap'; +import { SOLANA_WALLET_SNAP_ID } from '../../../shared/lib/accounts/solana-wallet-snap'; +import { + handleSnapRequest, + multichainUpdateBalance, +} from '../../store/actions'; +import { + useMultichainWalletSnapClient, + WalletClientType, +} from './useMultichainWalletSnapClient'; + +jest.mock('../../store/actions', () => ({ + handleSnapRequest: jest.fn(), + multichainUpdateBalance: jest.fn(), +})); + +const mockHandleSnapRequest = handleSnapRequest as jest.Mock; +const mockMultichainUpdateBalance = multichainUpdateBalance as jest.Mock; + +describe('useMultichainWalletSnapClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const testCases = [ + { + clientType: WalletClientType.Bitcoin, + network: MultichainNetworks.BITCOIN, + snapId: BITCOIN_WALLET_SNAP_ID, + mockAccount: { + address: 'tb1q2hjrlnf8kmtt5dj6e49gqzy6jnpe0sj7ty50cl', + id: '11a33c6b-0d46-43f4-a401-01587d575fd0', + options: {}, + methods: [BtcMethod.SendMany], + type: BtcAccountType.P2wpkh, + }, + }, + { + clientType: WalletClientType.Solana, + network: MultichainNetworks.SOLANA, + snapId: SOLANA_WALLET_SNAP_ID, + mockAccount: { + address: '4mip4tgbhxf8dpqvtb3zhzzapwfvznanhssqzgjyp7ha', + id: '22b44d7c-1e57-4b5b-8502-02698e686fd1', + options: {}, + methods: ['someMethod'], + // TODO: Update when keyring-api is published with Solana types + type: BtcAccountType.P2wpkh, + }, + }, + ]; + + testCases.forEach(({ clientType, network, snapId, mockAccount }) => { + it(`dispatches a Snap keyring request to create a ${clientType} account`, async () => { + const { result } = renderHook(() => + useMultichainWalletSnapClient(clientType), + ); + const multichainWalletSnapClient = result.current; + + mockHandleSnapRequest.mockResolvedValue(mockAccount); + + await multichainWalletSnapClient.createAccount(network); + expect(mockHandleSnapRequest).toHaveBeenCalledWith({ + origin: 'metamask', + snapId, + handler: HandlerType.OnKeyringRequest, + request: expect.any(Object), + }); + }); + + it(`force fetches the balance after creating a ${clientType} account`, async () => { + const { result } = renderHook(() => + useMultichainWalletSnapClient(clientType), + ); + const multichainWalletSnapClient = result.current; + + mockHandleSnapRequest.mockResolvedValue(mockAccount); + + await multichainWalletSnapClient.createAccount(network); + expect(mockMultichainUpdateBalance).toHaveBeenCalledWith(mockAccount.id); + }); + }); +}); diff --git a/ui/hooks/accounts/useBitcoinWalletSnapClient.ts b/ui/hooks/accounts/useMultichainWalletSnapClient.ts similarity index 60% rename from ui/hooks/accounts/useBitcoinWalletSnapClient.ts rename to ui/hooks/accounts/useMultichainWalletSnapClient.ts index debe911ac391..98dfa9b429d3 100644 --- a/ui/hooks/accounts/useBitcoinWalletSnapClient.ts +++ b/ui/hooks/accounts/useMultichainWalletSnapClient.ts @@ -1,32 +1,54 @@ import { KeyringClient, Sender } from '@metamask/keyring-api'; import { HandlerType } from '@metamask/snaps-utils'; import { CaipChainId, Json, JsonRpcRequest } from '@metamask/utils'; +import { SnapId } from '@metamask/snaps-sdk'; import { useMemo } from 'react'; import { handleSnapRequest, multichainUpdateBalance, } from '../../store/actions'; import { BITCOIN_WALLET_SNAP_ID } from '../../../shared/lib/accounts/bitcoin-wallet-snap'; +import { SOLANA_WALLET_SNAP_ID } from '../../../shared/lib/accounts/solana-wallet-snap'; + +export enum WalletClientType { + Bitcoin = 'bitcoin-wallet-snap', + Solana = 'solana-wallet-snap', +} + +const SNAP_ID_MAP: Record = { + [WalletClientType.Bitcoin]: BITCOIN_WALLET_SNAP_ID, + [WalletClientType.Solana]: SOLANA_WALLET_SNAP_ID, +}; + +export class MultichainWalletSnapSender implements Sender { + private snapId: SnapId; + + constructor(snapId: SnapId) { + this.snapId = snapId; + } -export class BitcoinWalletSnapSender implements Sender { send = async (request: JsonRpcRequest): Promise => { // We assume the caller of this module is aware of this. If we try to use this module // without having the pre-installed Snap, this will likely throw an error in // the `handleSnapRequest` action. return (await handleSnapRequest({ origin: 'metamask', - snapId: BITCOIN_WALLET_SNAP_ID, + snapId: this.snapId, handler: HandlerType.OnKeyringRequest, request, })) as Json; }; } -export class BitcoinWalletSnapClient { +export class MultichainWalletSnapClient { readonly #client: KeyringClient; - constructor() { - this.#client = new KeyringClient(new BitcoinWalletSnapSender()); + constructor(clientType: WalletClientType) { + const snapId = SNAP_ID_MAP[clientType]; + if (!snapId) { + throw new Error(`Unsupported client type: ${clientType}`); + } + this.#client = new KeyringClient(new MultichainWalletSnapSender(snapId)); } async createAccount(scope: CaipChainId) { @@ -43,10 +65,10 @@ export class BitcoinWalletSnapClient { } } -export function useBitcoinWalletSnapClient() { +export function useMultichainWalletSnapClient(clientType: WalletClientType) { const client = useMemo(() => { - return new BitcoinWalletSnapClient(); - }, []); + return new MultichainWalletSnapClient(clientType); + }, [clientType]); return client; }