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**
### **After**
After clicking the link in either state:
## **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();
+ });
});