Skip to content

Commit 5fa926d

Browse files
authored
feat: allows pasting external addresses for crosschain bridges (#30995)
<!-- 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** • If user has no Solana account: no external account pasting option • If user has Solana account: user can select dropdown any Solana accounts they have, and otherwise paste in an external account for dest account <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/30995?quickstart=1) ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMS-2103 ## **Manual testing steps** 1. Create an EVM <-> SOL bridge (either direction) 2. In the account picker, paste in an external account 3. Notice that you can select the external account ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/e2b6c6c8-fd36-4a27-ba8d-d19c58436bd0 <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [X] I've completed the PR template to the best of my ability - [X] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [X] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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 89af482 commit 5fa926d

File tree

7 files changed

+164
-10
lines changed

7 files changed

+164
-10
lines changed

app/_locales/en/messages.json

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/_locales/en_GB/messages.json

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ui/pages/bridge/hooks/useDestinationAccount.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useSelector } from 'react-redux';
22
import { useEffect, useState } from 'react';
3-
import type { InternalAccount } from '@metamask/keyring-internal-api';
43
import {
54
getSelectedInternalAccount,
65
getSelectedEvmInternalAccount,
@@ -13,10 +12,11 @@ import {
1312
import { useMultichainSelector } from '../../../hooks/useMultichainSelector';
1413
import { formatChainIdToCaip } from '../../../../shared/modules/bridge-utils/caip-formatters';
1514
import { MultichainNetworks } from '../../../../shared/constants/multichain/networks';
15+
import { DestinationAccount } from '../prepare/types';
1616

1717
export const useDestinationAccount = (isSwap = false) => {
1818
const [selectedDestinationAccount, setSelectedDestinationAccount] =
19-
useState<InternalAccount | null>(null);
19+
useState<DestinationAccount | null>(null);
2020

2121
const isEvm = useMultichainSelector(getMultichainIsEvm);
2222
const selectedEvmAccount = useSelector(getSelectedEvmInternalAccount);

ui/pages/bridge/prepare/components/destination-account-picker.tsx

+63-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React, { useMemo, useState } from 'react';
22
import { useSelector } from 'react-redux';
3-
import { InternalAccount } from '@metamask/keyring-internal-api';
43
import {
54
TextField,
65
Box,
@@ -25,12 +24,17 @@ import {
2524
} from '../../../../helpers/constants/design-system';
2625
// eslint-disable-next-line import/no-restricted-paths
2726
import { t } from '../../../../../app/scripts/translate';
27+
// eslint-disable-next-line import/no-restricted-paths
28+
import { isEthAddress } from '../../../../../app/scripts/lib/multichain/address';
29+
import { isSolanaAddress } from '../../../../../shared/lib/multichain/accounts';
30+
import { DestinationAccount } from '../types';
2831
import DestinationSelectedAccountListItem from './destination-selected-account-list-item';
2932
import DestinationAccountListItem from './destination-account-list-item';
33+
import { ExternalAccountListItem } from './external-account-list-item';
3034

3135
type DestinationAccountPickerProps = {
32-
onAccountSelect: (account: InternalAccount | null) => void;
33-
selectedSwapToAccount: InternalAccount | null;
36+
onAccountSelect: (account: DestinationAccount | null) => void;
37+
selectedSwapToAccount: DestinationAccount | null;
3438
isDestinationSolana: boolean;
3539
};
3640

@@ -43,13 +47,55 @@ export const DestinationAccountPicker = ({
4347
const selectedAccount = useSelector(getSelectedInternalAccount);
4448
const accounts = useSelector(getInternalAccounts);
4549

50+
// Check if search query is a valid address
51+
const isValidAddress = useMemo(() => {
52+
const trimmedQuery = searchQuery.trim();
53+
if (!trimmedQuery) {
54+
return false;
55+
}
56+
57+
return isDestinationSolana
58+
? isSolanaAddress(trimmedQuery)
59+
: isEthAddress(trimmedQuery);
60+
}, [searchQuery, isDestinationSolana]);
61+
62+
// Create an external account object if valid address is not in internal accounts
63+
const externalAccount = useMemo(() => {
64+
if (!isValidAddress) {
65+
return null;
66+
}
67+
68+
const trimmedQuery = searchQuery.trim();
69+
const addressExists = accounts.some(
70+
(account) => account.address.toLowerCase() === trimmedQuery.toLowerCase(),
71+
);
72+
73+
if (addressExists) {
74+
return null;
75+
}
76+
77+
return {
78+
address: trimmedQuery,
79+
metadata: {
80+
name: `${trimmedQuery.slice(0, 6)}...${trimmedQuery.slice(-4)}`,
81+
},
82+
isExternal: true,
83+
};
84+
}, [accounts, isValidAddress, searchQuery]);
85+
4686
const filteredAccounts = useMemo(
4787
() =>
4888
accounts.filter((account) => {
49-
const matchesSearch = account.metadata.name
89+
const matchesSearchByName = account.metadata.name
90+
.toLowerCase()
91+
.includes(searchQuery.toLowerCase());
92+
93+
const matchesSearchByAddress = account.address
5094
.toLowerCase()
5195
.includes(searchQuery.toLowerCase());
5296

97+
const matchesSearch = matchesSearchByName || matchesSearchByAddress;
98+
5399
const matchesChain = isDestinationSolana
54100
? isSolanaAccount(account)
55101
: !isSolanaAccount(account);
@@ -177,8 +223,20 @@ export const DestinationAccountPicker = ({
177223
showOptions={false}
178224
/>
179225
))}
226+
{externalAccount && (
227+
<ExternalAccountListItem
228+
key="external-account"
229+
account={externalAccount}
230+
selected={Boolean(
231+
selectedSwapToAccount &&
232+
(selectedSwapToAccount as DestinationAccount).address ===
233+
externalAccount.address,
234+
)}
235+
onClick={() => onAccountSelect(externalAccount)}
236+
/>
237+
)}
180238

181-
{filteredAccounts.length === 0 && (
239+
{filteredAccounts.length === 0 && !externalAccount && (
182240
<Box
183241
display={Display.Flex}
184242
style={{

ui/pages/bridge/prepare/components/destination-selected-account-list-item.tsx

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from 'react';
22
import classnames from 'classnames';
33
import { useSelector } from 'react-redux';
4-
import { InternalAccount } from '@metamask/keyring-internal-api';
54
import { shortenAddress } from '../../../../helpers/utils/util';
65

76
import {
@@ -24,9 +23,11 @@ import {
2423
import { getUseBlockie } from '../../../../selectors';
2524
// eslint-disable-next-line import/no-restricted-paths
2625
import { normalizeSafeAddress } from '../../../../../app/scripts/lib/multichain/address';
26+
import { useI18nContext } from '../../../../hooks/useI18nContext';
27+
import { DestinationAccount } from '../types';
2728

2829
type DestinationSelectedAccountListItemProps = {
29-
account: InternalAccount;
30+
account: DestinationAccount;
3031
selected: boolean;
3132
onClick?: () => void;
3233
};
@@ -35,6 +36,8 @@ const DestinationSelectedAccountListItem: React.FC<
3536
DestinationSelectedAccountListItemProps
3637
> = ({ account, selected, onClick }) => {
3738
const useBlockie = useSelector(getUseBlockie);
39+
const t = useI18nContext();
40+
const isExternalAccount = 'isExternal' in account && account.isExternal;
3841

3942
return (
4043
<Box
@@ -64,7 +67,7 @@ const DestinationSelectedAccountListItem: React.FC<
6467

6568
<Box display={Display.Flex} style={{ flexDirection: 'column' }}>
6669
<Text variant={TextVariant.bodyMdMedium} marginBottom={1}>
67-
{account.metadata.name}
70+
{isExternalAccount ? t('externalAccount') : account.metadata.name}
6871
</Text>
6972

7073
<Text
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import React from 'react';
2+
import classnames from 'classnames';
3+
import { useSelector } from 'react-redux';
4+
import { ExternalAccount } from '../types';
5+
import { shortenAddress } from '../../../../helpers/utils/util';
6+
import {
7+
AvatarAccount,
8+
AvatarAccountSize,
9+
AvatarAccountVariant,
10+
Box,
11+
Text,
12+
} from '../../../../components/component-library';
13+
import {
14+
AlignItems,
15+
BackgroundColor,
16+
BorderColor,
17+
Display,
18+
FlexDirection,
19+
TextColor,
20+
TextVariant,
21+
} from '../../../../helpers/constants/design-system';
22+
import { getUseBlockie } from '../../../../selectors';
23+
// eslint-disable-next-line import/no-restricted-paths
24+
import { normalizeSafeAddress } from '../../../../../app/scripts/lib/multichain/address';
25+
import { useI18nContext } from '../../../../hooks/useI18nContext';
26+
27+
type ExternalAccountListItemProps = {
28+
account: ExternalAccount;
29+
selected: boolean;
30+
onClick?: () => void;
31+
};
32+
33+
export const ExternalAccountListItem: React.FC<
34+
ExternalAccountListItemProps
35+
> = ({ account, selected, onClick }) => {
36+
const useBlockie = useSelector(getUseBlockie);
37+
const t = useI18nContext();
38+
39+
return (
40+
<Box
41+
display={Display.Flex}
42+
padding={4}
43+
backgroundColor={BackgroundColor.transparent}
44+
className={classnames('multichain-account-list-item', {
45+
'multichain-account-list-item--selected': selected,
46+
})}
47+
onClick={onClick}
48+
alignItems={AlignItems.center}
49+
>
50+
<AvatarAccount
51+
borderColor={BorderColor.transparent}
52+
size={AvatarAccountSize.Md}
53+
address={account.address}
54+
variant={
55+
useBlockie
56+
? AvatarAccountVariant.Blockies
57+
: AvatarAccountVariant.Jazzicon
58+
}
59+
marginInlineEnd={2}
60+
/>
61+
62+
<Box display={Display.Flex} flexDirection={FlexDirection.Column}>
63+
<Text variant={TextVariant.bodyMdMedium} marginBottom={1}>
64+
{t('externalAccount')}
65+
</Text>
66+
<Text
67+
variant={TextVariant.bodySm}
68+
color={TextColor.textAlternative}
69+
data-testid="account-list-address"
70+
>
71+
{shortenAddress(normalizeSafeAddress(account.address))}
72+
</Text>
73+
</Box>
74+
</Box>
75+
);
76+
};

ui/pages/bridge/prepare/types.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { InternalAccount } from '@metamask/keyring-internal-api';
2+
3+
export type ExternalAccount = {
4+
address: string;
5+
metadata: {
6+
name: string;
7+
};
8+
isExternal: boolean;
9+
};
10+
11+
export type DestinationAccount = InternalAccount | ExternalAccount;

0 commit comments

Comments
 (0)