From 5349e6a9be721d9072474e77e1dc163a07a6cbd9 Mon Sep 17 00:00:00 2001 From: Bilal <44588480+BZahory@users.noreply.github.com> Date: Wed, 13 Mar 2024 01:23:55 -0400 Subject: [PATCH] feat: add cross chain swaps link in extension (#23437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** As part of ongoing monetization efforts, Portfolio primitives are looking for opportunities to enable new revenue-generating flows. One area is the swaps page, where we can recommend users navigate to MetaMask Bridges if they wish to complete a cross-chain swap. Our hypothesis is that this will allow us to better target users that wish to complete both a swap and a bridge. [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/23437?quickstart=1) ## **Related issues** [METABRIDGE-846](https://consensyssoftware.atlassian.net/browse/METABRIDGE-846) ## **Manual testing steps** 1. Go to the Swaps page on any network support by *both* Bridges and Swaps (e.g., Ethereum Mainnet) 2. Verify that the button is available 3. Verify that it links to MetaMask Bridges, with the correct token and chain selected - Verify that the MetaMetrics ID is passed as a query parameter when the page is first loaded 4. Verify that it disappears when you begin to fetch a quote 5. Verify that it doesn't appear at all for networks supported by Swaps but unsupported by Bridges - You may need to remove a Swaps-supported network from `ALLOWED_BRIDGE_CHAIN_IDS`, or add a Swaps Testnet (chain ID 1337) to MetaMask to test this. ## **Screenshots/Recordings** ### **Before** Screenshot 2024-03-12 at 5 20 17 PM ### **After** Screenshot 2024-03-12 at 6 28 45 PM Screenshot 2024-03-12 at 6 29 07 PM After clicking the link in either state: Screenshot 2024-03-12 at 5 18 03 PM ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md). - [x] I've clearly explained what problem this PR is solving and how it is solved. - [x] I've linked related issues - [x] I've included manual testing steps - [x] I've included screenshots/recordings if applicable - [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/develop/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. - [x] I’ve properly set the pull request status: - [x] In case it's not yet "ready for review", I've set it to "draft". - [x] In case it's "ready for review", I've changed it from "draft" to "non-draft". ## **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. --- app/_locales/en/messages.json | 3 + .../prepare-swap-page/prepare-swap-page.js | 48 ++++++++++++++ .../prepare-swap-page.test.js | 65 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b6b2761880b5..2a2587a8c1fc 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1021,6 +1021,9 @@ "createSnapAccountTitle": { "message": "Create account" }, + "crossChainSwapsLink": { + "message": "Swap across networks with MetaMask Portfolio" + }, "cryptoCompare": { "message": "CryptoCompare" }, diff --git a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js index 67ff69a9f8c8..7d33d6035797 100644 --- a/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js +++ b/ui/pages/swaps/prepare-swap-page/prepare-swap-page.js @@ -24,6 +24,7 @@ import { SEVERITIES, TextVariant, BLOCK_SIZES, + FontWeight, } from '../../../helpers/constants/design-system'; import { fetchQuotesAndSetQuoteState, @@ -64,12 +65,15 @@ import { getTokenList, isHardwareWallet, getHardwareWalletType, + getIsBridgeChain, + getMetaMetricsId, } from '../../../selectors'; import { getValueFromWeiHex, hexToDecimal, } from '../../../../shared/modules/conversion.utils'; import { getURLHostName } from '../../../helpers/utils/util'; +import { getPortfolioUrl } from '../../../helpers/utils/portfolio'; import { usePrevious } from '../../../hooks/usePrevious'; import { useTokenTracker } from '../../../hooks/useTokenTracker'; import { useTokenFiatAmount } from '../../../hooks/useTokenFiatAmount'; @@ -197,6 +201,8 @@ export default function PrepareSwapPage({ const numberOfAggregators = aggregatorMetadata ? Object.keys(aggregatorMetadata).length : 0; + const isBridgeChain = useSelector(getIsBridgeChain); + const metaMetricsId = useSelector(getMetaMetricsId); const tokenConversionRates = useSelector(getTokenExchangeRates, isEqual); const conversionRate = useSelector(getConversionRate); @@ -713,6 +719,11 @@ export default function PrepareSwapPage({ !swapsErrorKey && !isReviewSwapButtonDisabled && !areQuotesPresent; const showNotEnoughTokenMessage = !fromTokenError && balanceError && fromTokenSymbol; + const showCrossChainSwapsLink = + isBridgeChain && + !showReviewQuote && + !showQuotesLoadingAnimation && + !areQuotesPresent; const tokenVerifiedOn1Source = occurrences === 1; @@ -1024,6 +1035,43 @@ export default function PrepareSwapPage({ + {showCrossChainSwapsLink && ( + { + const portfolioUrl = getPortfolioUrl( + 'bridge', + 'ext_bridge_button', + metaMetricsId, + ); + + global.platform.openTab({ + url: `${portfolioUrl}&token=${fromTokenAddress}`, + }); + + trackEvent({ + category: MetaMetricsEventCategory.Swaps, + event: MetaMetricsEventName.BridgeLinkClicked, + properties: { + location: 'Swaps', + text: 'Swap across networks with MetaMask Portfolio', + chain_id: chainId, + token_symbol: fromTokenSymbol, + }, + }); + }} + target="_blank" + data-testid="prepare-swap-page-cross-chain-swaps-link" + > + {t('crossChainSwapsLink')} + + )} {!showReviewQuote && toTokenIsNotDefault && occurrences < 2 && ( { fireEvent.click(maxLink); expect(setFromTokenInputValue).toHaveBeenCalled(); }); + + it('should have the Bridge link enabled if chain id is part of supported chains and there are no quotes', () => { + const mockStore = createSwapsMockStore(); + mockStore.metamask.providerConfig = { + chainId: '0x1', + }; + mockStore.metamask.swapsState.quotes = []; + const store = configureMockStore(middleware)(mockStore); + + const props = createProps(); + const { queryByTestId } = renderWithProvider( + , + store, + ); + const bridgeButton = queryByTestId( + 'prepare-swap-page-cross-chain-swaps-link', + ); + expect(bridgeButton).toBeInTheDocument(); + expect(bridgeButton).toBeEnabled(); + }); + + it('should not have the Bridge link enabled if chain id is part of supported chains but there are quotes', () => { + const mockStore = createSwapsMockStore(); + mockStore.metamask.providerConfig = { + chainId: '0x1', + }; + expect( + Object.keys(mockStore.metamask.swapsState.quotes).length, + ).toBeDefined(); + const store = configureMockStore(middleware)(mockStore); + + const props = createProps(); + const { queryByTestId } = renderWithProvider( + , + store, + ); + const bridgeButton = queryByTestId( + 'prepare-swap-page-cross-chain-swaps-link', + ); + + expect(bridgeButton).toBeNull(); + }); + + it('should not have the Bridge link enabled if there are quotes but chain id is not part of supported chains', () => { + const mockStore = createSwapsMockStore(); + mockStore.metamask.providerConfig = { + chainId: '0x539', // swaps testnet + }; + expect( + Object.keys(mockStore.metamask.swapsState.quotes).length, + ).toBeDefined(); + + const store = configureMockStore(middleware)(mockStore); + + const props = createProps(); + const { queryByTestId } = renderWithProvider( + , + store, + ); + const bridgeButton = queryByTestId( + 'prepare-swap-page-cross-chain-swaps-link', + ); + + expect(bridgeButton).toBeNull(); + }); });