Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: (MMS-2061) prompt user to create solana acct in bridge flow if selected tochain and no account #30847

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions app/_locales/en_GB/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
getMultichainSelectedAccountCachedBalance,
getMultichainIsEvm,
} from '../../../../selectors/multichain';
import { needsSolanaAccountForDestination } from '../../../../ducks/bridge/selectors';
import { MultichainNetworks } from '../../../../../shared/constants/multichain/networks';
import { getAssetsMetadata } from '../../../../selectors/assets';
import { Numeric } from '../../../../../shared/modules/Numeric';
Expand All @@ -73,6 +74,7 @@ import { AssetPickerModalNftTab } from './asset-picker-modal-nft-tab';
import AssetList from './AssetList';
import { Search } from './asset-picker-modal-search';
import { AssetPickerModalNetwork } from './asset-picker-modal-network';
import { SolanaAccountCreationPrompt } from './solana-account-creation-prompt';

type AssetPickerModalProps = {
header: JSX.Element | string | null;
Expand Down Expand Up @@ -141,6 +143,9 @@ export function AssetPickerModal({

const [searchQuery, setSearchQuery] = useState('');

// Check if we need to show the Solana account creation UI
const needsSolanaAccount = useSelector(needsSolanaAccountForDestination);

const swapsBlockedTokens = useSelector(getSwapsBlockedTokens);
const memoizedSwapsBlockedTokens = useMemo(() => {
return new Set<string>(swapsBlockedTokens);
Expand Down Expand Up @@ -535,43 +540,53 @@ export function AssetPickerModal({
</Box>
)}
<Box className="modal-tab__wrapper">
<AssetPickerModalTabs {...tabProps}>
<React.Fragment key={TabName.TOKENS}>
<Search
searchQuery={searchQuery}
onChange={(value) => setSearchQuery(value)}
autoFocus={autoFocus}
/>
<AssetList
network={network}
handleAssetChange={handleAssetChange}
asset={asset?.type === AssetType.NFT ? undefined : asset}
tokenList={filteredTokenList}
isTokenDisabled={getIsDisabled}
isTokenListLoading={isTokenListLoading}
assetItemProps={{
isTitleNetworkName:
// For src cross-chain swaps assets
isMultiselectEnabled,
isTitleHidden:
// For dest cross-chain swaps assets
!isSelectedNetworkActive,
}}
/>
</React.Fragment>
<AssetPickerModalNftTab
key={TabName.NFTS}
searchQuery={searchQuery}
onClose={onClose}
renderSearch={() => (
{/* Show Solana account creation prompt if the destination is Solana but no Solana account exists */}
{needsSolanaAccount ? (
<SolanaAccountCreationPrompt
onSuccess={() => {
// Refresh the component after account creation
onClose();
}}
/>
) : (
<AssetPickerModalTabs {...tabProps}>
<React.Fragment key={TabName.TOKENS}>
<Search
isNFTSearch
searchQuery={searchQuery}
onChange={(value) => setSearchQuery(value)}
autoFocus={autoFocus}
/>
)}
/>
</AssetPickerModalTabs>
<AssetList
network={network}
handleAssetChange={handleAssetChange}
asset={asset?.type === AssetType.NFT ? undefined : asset}
tokenList={filteredTokenList}
isTokenDisabled={getIsDisabled}
isTokenListLoading={isTokenListLoading}
assetItemProps={{
isTitleNetworkName:
// For src cross-chain swaps assets
isMultiselectEnabled,
isTitleHidden:
// For dest cross-chain swaps assets
!isSelectedNetworkActive,
}}
/>
</React.Fragment>
<AssetPickerModalNftTab
key={TabName.NFTS}
searchQuery={searchQuery}
onClose={onClose}
renderSearch={() => (
<Search
isNFTSearch
searchQuery={searchQuery}
onChange={(value) => setSearchQuery(value)}
/>
)}
/>
</AssetPickerModalTabs>
)}
</Box>
</ModalContent>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react';
import { useSelector } from 'react-redux';
import {
Box,
Text,
Button,
ButtonSize,
ButtonVariant,
} from '../../../component-library';
import {
Display,
TextAlign,
TextColor,
TextVariant,
AlignItems,
JustifyContent,
FlexDirection,
} from '../../../../helpers/constants/design-system';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import { MultichainNetworks } from '../../../../../shared/constants/multichain/networks';
import { getMetaMaskKeyrings } from '../../../../selectors';
import {
WalletClientType,
useMultichainWalletSnapClient,
} from '../../../../hooks/accounts/useMultichainWalletSnapClient';

export const SolanaAccountCreationPrompt = ({
onSuccess,
}: {
onSuccess: () => void;
}) => {
const t = useI18nContext();
const solanaWalletSnapClient = useMultichainWalletSnapClient(
WalletClientType.Solana,
);
const [primaryKeyring] = useSelector(getMetaMaskKeyrings);
const [isCreating, setIsCreating] = React.useState(false);

const handleCreateAccount = async () => {
try {
setIsCreating(true);
await solanaWalletSnapClient.createAccount(
MultichainNetworks.SOLANA,
primaryKeyring.metadata.id,
);
onSuccess();
} catch (error) {
console.error('Error creating Solana account:', error);
} finally {
setIsCreating(false);
}
};

return (
<Box
display={Display.Flex}
flexDirection={FlexDirection.Column}
alignItems={AlignItems.center}
justifyContent={JustifyContent.flexStart}
gap={4}
padding={4}
className="solana-account-creation-prompt"
data-testid="solana-account-creation-prompt"
style={{ height: '100vh', paddingTop: '72px' }}
>
<img
src="/images/solana-logo.svg"
alt="Solana Logo"
style={{ width: '60px', height: '60px', marginBottom: '8px' }}
/>

<Text
variant={TextVariant.headingSm}
textAlign={TextAlign.Center}
color={TextColor.textDefault}
>
{t('bridgeCreateSolanaAccountTitle')}
</Text>

<Text
variant={TextVariant.bodySm}
textAlign={TextAlign.Center}
color={TextColor.textAlternative}
>
{t('bridgeCreateSolanaAccountDescription')}
</Text>

<Button
block
size={ButtonSize.Md}
variant={ButtonVariant.Secondary}
onClick={handleCreateAccount}
loading={isCreating}
data-testid="create-solana-account-button"
style={{ width: '75%' }}
>
{t('createSolanaAccount')}
</Button>
</Box>
);
};
63 changes: 54 additions & 9 deletions ui/ducks/bridge/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
///: END:ONLY_INCLUDE_IF
NetworkState,
} from '@metamask/network-controller';
import { SolAccountType } from '@metamask/keyring-api';
import { AccountsControllerState } from '@metamask/accounts-controller';
import { orderBy, uniqBy } from 'lodash';
import { createSelector } from 'reselect';
import type { GasFeeEstimates } from '@metamask/gas-fee-controller';
Expand Down Expand Up @@ -79,7 +81,8 @@ import type { BridgeState } from './bridge';

export type BridgeAppState = {
metamask: BridgeControllerState &
NetworkState & {
NetworkState &
AccountsControllerState & {
useExternalServices: boolean;
currencyRates: {
[currency: string]: {
Expand All @@ -91,6 +94,18 @@ export type BridgeAppState = {
bridge: BridgeState;
};

// checks if the user has any solana accounts created
export const hasSolanaAccounts = (state: BridgeAppState) => {
// Access accounts from the state
const accounts = state.metamask.internalAccounts?.accounts || {};

// Check if any account is a Solana account
return Object.values(accounts).some((account) => {
const { DataAccount } = SolAccountType;
return Boolean(account && account.type === DataAccount);
});
};

// only includes networks user has added
export const getAllBridgeableNetworks = createDeepEqualSelector(
getNetworkConfigurationsByChainId,
Expand Down Expand Up @@ -124,8 +139,18 @@ export const getAllBridgeableNetworks = createDeepEqualSelector(
export const getFromChains = createDeepEqualSelector(
getAllBridgeableNetworks,
(state: BridgeAppState) => state.metamask.bridgeState?.bridgeFeatureFlags,
(allBridgeableNetworks, bridgeFeatureFlags) => {
return allBridgeableNetworks.filter(
(state: BridgeAppState) => hasSolanaAccounts(state),
(allBridgeableNetworks, bridgeFeatureFlags, hasSolanaAccount) => {
// First filter out Solana from source chains if no Solana account exists
const filteredNetworks = hasSolanaAccount
? allBridgeableNetworks
: allBridgeableNetworks.filter(
// @ts-expect-error: gotta fix type here.
({ chainId }) => chainId !== MultichainNetworks.SOLANA,
);

// Then apply the standard filter for active source chains
return filteredNetworks.filter(
({ chainId }) =>
bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].chains[
formatChainIdToCaip(chainId)
Expand All @@ -150,7 +175,7 @@ export const getToChains = createDeepEqualSelector(
(allBridgeableNetworks, bridgeFeatureFlags) =>
uniqBy([...allBridgeableNetworks, ...FEATURED_RPCS], 'chainId').filter(
({ chainId }) =>
bridgeFeatureFlags[BridgeFeatureFlagsKey.EXTENSION_CONFIG].chains[
bridgeFeatureFlags?.[BridgeFeatureFlagsKey.EXTENSION_CONFIG]?.chains?.[
formatChainIdToCaip(chainId)
]?.isActiveDest,
),
Expand All @@ -171,12 +196,14 @@ export const getTopAssetsFromFeatureFlags = (

export const getToChain = createSelector(
getToChains,
(state: BridgeAppState) => state.bridge.toChainId,
(state: BridgeAppState) => state.bridge?.toChainId,
(toChains, toChainId) =>
toChains.find(
({ chainId }) =>
chainId === toChainId || formatChainIdToCaip(chainId) === toChainId,
),
toChainId
? toChains.find(
({ chainId }) =>
chainId === toChainId || formatChainIdToCaip(chainId) === toChainId,
)
: undefined,
);

export const getFromToken = createSelector(
Expand Down Expand Up @@ -680,6 +707,24 @@ export const isBridgeSolanaEnabled = createDeepEqualSelector(
},
);

/**
* Checks if the destination chain is Solana and the user has no Solana accounts
*/
export const needsSolanaAccountForDestination = createDeepEqualSelector(
getToChain,
(state: BridgeAppState) => hasSolanaAccounts(state),
(toChain, hasSolanaAccount) => {
if (!toChain) {
return false;
}

const isSolanaDestination =
formatChainIdToCaip(toChain.chainId) === MultichainNetworks.SOLANA;

return isSolanaDestination && !hasSolanaAccount;
},
);

export const getIsToOrFromSolana = createSelector(
getFromChain,
getToChain,
Expand Down
Loading