Skip to content

Commit

Permalink
feat: solana swap and bridge navigation (#29705)
Browse files Browse the repository at this point in the history
<!--
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**

Enables swap and bridge buttons for non-evm networks and makes both of
those buttons navigate to the bridge interface. Also fences the feature
behind a code fence at the build level.

<!--
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/29705?quickstart=1)

## **Related issues**

Fixes:

## **Manual testing steps**

1. Run repo with "yarn start:flask" command
2. Enable "Add a new Solana account (Beta)" toggle in experimental
settings
3. Add a Solana account through the account dropdown
4. Go to home page while having Solana account selected
5. Click swap or bridge
6. See bridge interface

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [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**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] 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.
  • Loading branch information
bfullam authored Jan 22, 2025
1 parent ed2fb49 commit 01c9fbd
Show file tree
Hide file tree
Showing 13 changed files with 131 additions and 58 deletions.
2 changes: 2 additions & 0 deletions builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ buildTypes:
- build-flask
- keyring-snaps
- solana
- solana-swaps
env:
- INFURA_FLASK_PROJECT_ID
- SEGMENT_FLASK_WRITE_KEY
Expand Down Expand Up @@ -139,6 +140,7 @@ features:
solana:
assets:
- ./{app,shared,ui}/**/solana/**
solana-swaps:

# Env variables that are required for all types of builds
#
Expand Down
13 changes: 13 additions & 0 deletions shared/constants/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
import { MultichainNetworks } from './multichain/networks';
///: END:ONLY_INCLUDE_IF
import { CHAIN_IDS, NETWORK_TO_NAME_MAP } from './network';

// TODO read from feature flags
Expand All @@ -11,6 +14,9 @@ export const ALLOWED_BRIDGE_CHAIN_IDS = [
CHAIN_IDS.ARBITRUM,
CHAIN_IDS.LINEA_MAINNET,
CHAIN_IDS.BASE,
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
MultichainNetworks.SOLANA,
///: END:ONLY_INCLUDE_IF
];

export type AllowedBridgeChainIds = (typeof ALLOWED_BRIDGE_CHAIN_IDS)[number];
Expand Down Expand Up @@ -45,6 +51,13 @@ export const NETWORK_TO_SHORT_NETWORK_NAME_MAP: Record<
[CHAIN_IDS.OPTIMISM]: NETWORK_TO_NAME_MAP[CHAIN_IDS.OPTIMISM],
[CHAIN_IDS.ZKSYNC_ERA]: 'ZkSync Era',
[CHAIN_IDS.BASE]: 'Base',
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
[MultichainNetworks.SOLANA]: 'Solana',
[MultichainNetworks.SOLANA_TESTNET]: 'Solana Testnet',
[MultichainNetworks.SOLANA_DEVNET]: 'Solana Devnet',
[MultichainNetworks.BITCOIN]: 'Bitcoin',
[MultichainNetworks.BITCOIN_TESTNET]: 'Bitcoin Testnet',
///: END:ONLY_INCLUDE_IF
};
export const BRIDGE_MM_FEE_RATE = 0.875;
export const REFRESH_INTERVAL_MS = 30 * 1000;
Expand Down
6 changes: 6 additions & 0 deletions shared/constants/swaps.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
import { MultichainNetworks } from './multichain/networks';
///: END:ONLY_INCLUDE_IF
import {
ETH_TOKEN_IMAGE_URL,
TEST_ETH_TOKEN_IMAGE_URL,
Expand Down Expand Up @@ -181,6 +184,9 @@ export const ALLOWED_PROD_SWAPS_CHAIN_IDS = [
CHAIN_IDS.ZKSYNC_ERA,
CHAIN_IDS.LINEA_MAINNET,
CHAIN_IDS.BASE,
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
MultichainNetworks.SOLANA,
///: END:ONLY_INCLUDE_IF
] as const;

export const ALLOWED_DEV_SWAPS_CHAIN_IDS = [
Expand Down
24 changes: 12 additions & 12 deletions test/e2e/flask/solana/send-flow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@ describe.skip('Send full flow of USD', function (this: Suite) {
);
assert.equal(
await homePage.check_ifSwapButtonIsClickable(),
false,
'Swap button is enabled and it shouldn`t',
true,
'Swap button is not enabled and it should',
);
assert.equal(
await homePage.check_ifBridgeButtonIsClickable(),
false,
'Bridge button is enabled and it should`t',
true,
'Bridge button is not enabled and it should',
);
await homePage.clickOnSendButton();
const sendSolanaPage = new SendSolanaPage(driver);
Expand Down Expand Up @@ -234,13 +234,13 @@ describe.skip('Send full flow of SOL', function (this: Suite) {
);
assert.equal(
await homePage.check_ifSwapButtonIsClickable(),
false,
'Swap button is enabled and it shouldn`t',
true,
'Swap button is not enabled and it should',
);
assert.equal(
await homePage.check_ifBridgeButtonIsClickable(),
false,
'Bridge button is enabled and it should`t',
true,
'Bridge button is not enabled and it should',
);
await homePage.clickOnSendButton();
const sendSolanaPage = new SendSolanaPage(driver);
Expand Down Expand Up @@ -378,13 +378,13 @@ describe.skip('Send flow flow', function (this: Suite) {
);
assert.equal(
await homePage.check_ifSwapButtonIsClickable(),
false,
'Swap button is enabled and it should`t',
true,
'Swap button is not enabled and it should',
);
assert.equal(
await homePage.check_ifBridgeButtonIsClickable(),
false,
'Bridge button is enabled and it should`t',
true,
'Bridge button is not enabled and it should',
);
await homePage.clickOnSendButton();
const sendSolanaPage = new SendSolanaPage(driver);
Expand Down
2 changes: 2 additions & 0 deletions test/e2e/page-objects/pages/home/non-evm-homepage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class NonEvmHomepage extends HomePage {

protected readonly swapButton = '[data-testid="token-overview-button-swap"]';

protected readonly bridgeButton = '[data-testid="coin-overview-bridge"]';

/**
* Clicks the send button on the non-EVM account homepage.
*/
Expand Down
81 changes: 47 additions & 34 deletions ui/components/app/wallet-overview/coin-buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,14 @@ import {
import { isMultichainWalletSnap } from '../../../../shared/lib/accounts/snaps';
///: END:ONLY_INCLUDE_IF
import {
getMultichainIsEvm,
getMultichainNativeCurrency,
getMultichainNetwork,
} from '../../../selectors/multichain';
import { useMultichainSelector } from '../../../hooks/useMultichainSelector';
import { getCurrentChainId } from '../../../../shared/modules/selectors/networks';
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
import { MultichainNetworks } from '../../../../shared/constants/multichain/networks';
///: END:ONLY_INCLUDE_IF

type CoinButtonsProps = {
account: InternalAccount;
Expand Down Expand Up @@ -160,12 +163,15 @@ const CoinButtons = ({
// Initially, those events were using a "ETH" as `token_symbol`, so we keep this behavior
// for EVM, no matter the currently selected native token (e.g. SepoliaETH if you are on Sepolia
// network).
const isEvm = useMultichainSelector(getMultichainIsEvm, account);
const { isEvmNetwork, chainId: multichainChainId } = useMultichainSelector(
getMultichainNetwork,
account,
);
const multichainNativeToken = useMultichainSelector(
getMultichainNativeCurrency,
account,
);
const nativeToken = isEvm ? 'ETH' : multichainNativeToken;
const nativeToken = isEvmNetwork ? 'ETH' : multichainNativeToken;

const isExternalServicesEnabled = useSelector(getUseExternalServices);

Expand Down Expand Up @@ -331,7 +337,7 @@ const CoinButtons = ({
///: END:ONLY_INCLUDE_IF

const setCorrectChain = useCallback(async () => {
if (currentChainId !== chainId) {
if (currentChainId !== chainId && multichainChainId !== chainId) {
try {
const networkConfigurationId = networks[chainId];
await dispatch(setActiveNetworkWithError(networkConfigurationId));
Expand Down Expand Up @@ -404,7 +410,44 @@ const CoinButtons = ({
history.push(SEND_ROUTE);
}, [chainId, account, setCorrectChain]);

///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
const handleBuyAndSellOnClick = useCallback(() => {
openBuyCryptoInPdapp(getChainId());
trackEvent({
event: MetaMetricsEventName.NavBuyButtonClicked,
category: MetaMetricsEventCategory.Navigation,
properties: {
account_type: account.type,
location: 'Home',
text: 'Buy',
chain_id: chainId,
token_symbol: defaultSwapsToken,
...getSnapAccountMetaMetricsPropertiesIfAny(account),
},
});
}, [chainId, defaultSwapsToken]);

const handleBridgeOnClick = useCallback(async () => {
if (!defaultSwapsToken) {
return;
}
await setCorrectChain();
openBridgeExperience(
'Home',
defaultSwapsToken,
location.pathname.includes('asset') ? '&token=native' : '',
);
}, [defaultSwapsToken, location, openBridgeExperience]);
///: END:ONLY_INCLUDE_IF

const handleSwapOnClick = useCallback(async () => {
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
if (multichainChainId === MultichainNetworks.SOLANA) {
handleBridgeOnClick();
return;
}
///: END:ONLY_INCLUDE_IF

await setCorrectChain();
///: BEGIN:ONLY_INCLUDE_IF(build-mmi)
global.platform.openTab({
Expand Down Expand Up @@ -447,36 +490,6 @@ const CoinButtons = ({
///: END:ONLY_INCLUDE_IF
]);

///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
const handleBuyAndSellOnClick = useCallback(() => {
openBuyCryptoInPdapp(getChainId());
trackEvent({
event: MetaMetricsEventName.NavBuyButtonClicked,
category: MetaMetricsEventCategory.Navigation,
properties: {
account_type: account.type,
location: 'Home',
text: 'Buy',
chain_id: chainId,
token_symbol: defaultSwapsToken,
...getSnapAccountMetaMetricsPropertiesIfAny(account),
},
});
}, [chainId, defaultSwapsToken]);

const handleBridgeOnClick = useCallback(async () => {
if (!defaultSwapsToken) {
return;
}
await setCorrectChain();
openBridgeExperience(
'Home',
defaultSwapsToken,
location.pathname.includes('asset') ? '&token=native' : '',
);
}, [defaultSwapsToken, location, openBridgeExperience]);
///: END:ONLY_INCLUDE_IF

return (
<Box display={Display.Flex} justifyContent={JustifyContent.spaceEvenly}>
{
Expand Down
2 changes: 2 additions & 0 deletions ui/components/app/wallet-overview/non-evm-overview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ describe('NonEvmOverview', () => {
location: 'Home',
snap_id: mockNonEvmAccount.metadata.snap.id,
text: 'Buy',
// We use a `SwapsEthToken` in this case, so we're expecting an entire object here.
token_symbol: expect.any(Object),
},
});
});
Expand Down
22 changes: 19 additions & 3 deletions ui/components/app/wallet-overview/non-evm-overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {
import { getIsBitcoinBuyable } from '../../../ducks/ramps';
import { useMultichainSelector } from '../../../hooks/useMultichainSelector';
///: END:ONLY_INCLUDE_IF
import { getSelectedInternalAccount } from '../../../selectors';
import {
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
getIsSwapsChain,
getIsBridgeChain,
///: END:ONLY_INCLUDE_IF
getSelectedInternalAccount,
getSwapsDefaultToken,
} from '../../../selectors';
import { CoinOverview } from './coin-overview';

type NonEvmOverviewProps = {
Expand All @@ -37,6 +44,14 @@ const NonEvmOverview = ({ className }: NonEvmOverviewProps) => {
const isBtc = accountType === BtcAccountType.P2wpkh;
const isBuyableChain = isBtc ? isBtcBuyable && isBtcMainnetAccount : false;
///: END:ONLY_INCLUDE_IF
const defaultSwapsToken = useSelector(getSwapsDefaultToken);

let isSwapsChain = false;
let isBridgeChain = false;
///: BEGIN:ONLY_INCLUDE_IF(solana-swaps)
isSwapsChain = useSelector((state) => getIsSwapsChain(state, chainId));
isBridgeChain = useSelector((state) => getIsBridgeChain(state, chainId));
///: END:ONLY_INCLUDE_IF

return (
<CoinOverview
Expand All @@ -47,9 +62,10 @@ const NonEvmOverview = ({ className }: NonEvmOverviewProps) => {
className={className}
chainId={chainId}
isSigningEnabled={true}
isSwapsChain={false}
isSwapsChain={isSwapsChain}
defaultSwapsToken={defaultSwapsToken}
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
isBridgeChain={false}
isBridgeChain={isBridgeChain}
isBuyableChain={isBuyableChain}
///: END:ONLY_INCLUDE_IF
/>
Expand Down
5 changes: 4 additions & 1 deletion ui/pages/bridge/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { useGasFeeEstimates } from '../../hooks/useGasFeeEstimates';
import { useBridgeExchangeRates } from '../../hooks/bridge/useBridgeExchangeRates';
import { useQuoteFetchEvents } from '../../hooks/bridge/useQuoteFetchEvents';
import { TextVariant } from '../../helpers/constants/design-system';
import { getMultichainIsSolana } from '../../selectors/multichain';
import PrepareBridgePage from './prepare/prepare-bridge-page';
import AwaitingSignaturesCancelButton from './awaiting-signatures/awaiting-signatures-cancel-button';
import AwaitingSignatures from './awaiting-signatures/awaiting-signatures';
Expand Down Expand Up @@ -83,6 +84,8 @@ const CrossChainSwap = () => {

const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);

const isSolana = useSelector(getMultichainIsSolana);

return (
<Page className="bridge__container">
<Header
Expand All @@ -106,7 +109,7 @@ const CrossChainSwap = () => {
/>
}
>
{t('bridge')}
{isSolana ? t('swap') : t('bridge')}
</Header>
<Content padding={0}>
<Switch>
Expand Down
5 changes: 4 additions & 1 deletion ui/pages/bridge/prepare/bridge-input-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { shortenString } from '../../../helpers/utils/util';
import type { BridgeToken } from '../../../../shared/types/bridge';
import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
import { MINUTE } from '../../../../shared/constants/time';
import { getMultichainIsSolana } from '../../../selectors/multichain';
import { BridgeAssetPickerButton } from './components/bridge-asset-picker-button';

export const BridgeInputGroup = ({
Expand Down Expand Up @@ -99,6 +100,8 @@ export const BridgeInputGroup = ({
}
}, [amountFieldProps?.value, isAmountReadOnly, token]);

const isSolana = useSelector(getMultichainIsSolana);

return (
<Column paddingInline={6} gap={1}>
<Row gap={4}>
Expand Down Expand Up @@ -172,7 +175,7 @@ export const BridgeInputGroup = ({
fontWeight={FontWeight.Normal}
style={{ whiteSpace: 'nowrap' }}
>
{t('bridgeTo')}
{isSolana ? t('swapSwapTo') : t('bridgeTo')}
</Button>
) : (
<BridgeAssetPickerButton
Expand Down
5 changes: 4 additions & 1 deletion ui/pages/bridge/prepare/prepare-bridge-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { getCurrentKeyring, getLocale, getTokenList } from '../../../selectors';
import { isHardwareKeyring } from '../../../helpers/utils/hardware';
import { SECOND } from '../../../../shared/constants/time';
import { BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE } from '../../../../shared/constants/bridge';
import { getMultichainIsSolana } from '../../../selectors/multichain';
import { BridgeInputGroup } from './bridge-input-group';
import { BridgeCTAButton } from './bridge-cta-button';

Expand Down Expand Up @@ -369,6 +370,8 @@ const PrepareBridgePage = () => {
}
}, [fromChain, fromToken, fromTokens, search, isFromTokensLoading]);

const isSolana = useSelector(getMultichainIsSolana);

return (
<Column className="prepare-bridge-page" gap={8}>
<BridgeInputGroup
Expand Down Expand Up @@ -520,7 +523,7 @@ const PrepareBridgePage = () => {
dispatch(setToChain(networkConfig.chainId));
dispatch(setToToken(null));
},
header: t('bridgeTo'),
header: isSolana ? t('swapSwapTo') : t('bridgeTo'),
shouldDisableNetwork: ({ chainId }) =>
chainId === fromChain?.chainId,
}}
Expand Down
10 changes: 10 additions & 0 deletions ui/selectors/multichain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,16 @@ export function getMultichainIsBitcoin(
return !isEvm && symbol === 'BTC';
}

export function getMultichainIsSolana(
state: MultichainState,
account?: InternalAccount,
) {
const isEvm = getMultichainIsEvm(state, account);
const { symbol } = getMultichainDefaultToken(state, account);

return !isEvm && symbol === 'SOL';
}

/**
* Retrieves the provider configuration for a multichain network.
*
Expand Down
Loading

0 comments on commit 01c9fbd

Please sign in to comment.