From 95f830438a89d9405a08c2fde025626a8b7407b2 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Mon, 21 Mar 2022 10:26:52 +0100 Subject: [PATCH] Add a new fiat onboarding option via MoonPay (#13934) --- app/_locales/en/messages.json | 10 +++ app/scripts/constants/on-ramp.js | 1 + app/scripts/lib/buy-url.js | 45 ++++++++++- app/scripts/lib/buy-url.test.js | 33 ++++++++- shared/constants/network.js | 21 ++++++ shared/constants/swaps.js | 2 +- test/e2e/webdriver/index.js | 2 +- test/jest/constants.js | 2 +- .../deposit-ether-modal.component.js | 74 ++++++++++++------- .../deposit-ether-modal.container.js | 5 ++ .../app/modals/deposit-ether-modal/index.scss | 4 + .../__snapshots__/logo-moonpay.test.js.snap | 19 +++++ ui/components/ui/logo/logo-moonpay.js | 45 +++++++++++ ui/components/ui/logo/logo-moonpay.test.js | 11 +++ ui/ducks/swaps/swaps.test.js | 4 +- ui/pages/swaps/swaps.util.test.js | 8 +- ui/selectors/selectors.js | 5 ++ ui/store/actions.js | 10 ++- 18 files changed, 261 insertions(+), 40 deletions(-) create mode 100644 ui/components/ui/logo/__snapshots__/logo-moonpay.test.js.snap create mode 100644 ui/components/ui/logo/logo-moonpay.js create mode 100644 ui/components/ui/logo/logo-moonpay.test.js diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 1997ea777..ae3152787 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -411,6 +411,13 @@ "buy": { "message": "Buy" }, + "buyCryptoWithMoonPay": { + "message": "Buy $1 with MoonPay", + "description": "$1 represents the cypto symbol to be purchased" + }, + "buyCryptoWithMoonPayDescription": { + "message": "MoonPay supports popular payment methods, including Visa, Mastercard, Apple / Google / Samsung Pay, and bank transfers in 146+ countries. Tokens deposit into your MetaMask account." + }, "buyCryptoWithTransak": { "message": "Buy $1 with Transak", "description": "$1 represents the cypto symbol to be purchased" @@ -617,6 +624,9 @@ "continue": { "message": "Continue" }, + "continueToMoonPay": { + "message": "Continue to MoonPay" + }, "continueToTransak": { "message": "Continue to Transak" }, diff --git a/app/scripts/constants/on-ramp.js b/app/scripts/constants/on-ramp.js index 335d7a9ad..bfce16f70 100644 --- a/app/scripts/constants/on-ramp.js +++ b/app/scripts/constants/on-ramp.js @@ -1 +1,2 @@ export const TRANSAK_API_KEY = '25ac1309-a49b-4411-b20e-5e56c61a5b1c'; // It's a public key, which will be included in a URL for Transak. +export const MOONPAY_API_KEY = 'pk_live_WbCpe6PxSIcGPCSd6lKCbJNRht7uy'; // Publishable key. diff --git a/app/scripts/lib/buy-url.js b/app/scripts/lib/buy-url.js index a7f23ccc3..ba4a0d86f 100644 --- a/app/scripts/lib/buy-url.js +++ b/app/scripts/lib/buy-url.js @@ -12,7 +12,7 @@ import { } from '../../../shared/constants/network'; import { SECOND } from '../../../shared/constants/time'; import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; -import { TRANSAK_API_KEY } from '../constants/on-ramp'; +import { TRANSAK_API_KEY, MOONPAY_API_KEY } from '../constants/on-ramp'; const fetchWithTimeout = getFetchWithTimeout(SECOND * 30); @@ -67,6 +67,47 @@ const createTransakUrl = (walletAddress, chainId) => { return `https://global.transak.com/?${queryParams}`; }; +/** + * Create a MoonPay Checkout URL. + * + * @param {string} walletAddress - Destination address + * @param {string} chainId - Current chain ID + * @returns String + */ +const createMoonPayUrl = async (walletAddress, chainId) => { + const { + moonPay: { defaultCurrencyCode, showOnlyCurrencies } = {}, + } = BUYABLE_CHAINS_MAP[chainId]; + const moonPayQueryParams = new URLSearchParams({ + apiKey: MOONPAY_API_KEY, + walletAddress, + defaultCurrencyCode, + showOnlyCurrencies, + }); + const queryParams = new URLSearchParams({ + url: `https://buy.moonpay.com?${moonPayQueryParams}`, + context: 'extension', + }); + const moonPaySignUrl = `${SWAPS_API_V2_BASE_URL}/moonpaySign/?${queryParams}`; + try { + const response = await fetchWithTimeout(moonPaySignUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + const parsedResponse = await response.json(); + if (response.ok && parsedResponse.url) { + return parsedResponse.url; + } + log.warn('Failed to create a MoonPay purchase URL', parsedResponse); + } catch (err) { + log.warn('Failed to create a MoonPay purchase URL', err); + } + return ''; +}; + /** * Gives the caller a url at which the user can acquire eth, depending on the network they are in * @@ -89,6 +130,8 @@ export default async function getBuyUrl({ chainId, address, service }) { return await createWyrePurchaseUrl(address); case 'transak': return createTransakUrl(address, chainId); + case 'moonpay': + return createMoonPayUrl(address, chainId); case 'metamask-faucet': return 'https://faucet.metamask.io/'; case 'rinkeby-faucet': diff --git a/app/scripts/lib/buy-url.test.js b/app/scripts/lib/buy-url.test.js index 71c270fae..b582bae19 100644 --- a/app/scripts/lib/buy-url.test.js +++ b/app/scripts/lib/buy-url.test.js @@ -9,7 +9,7 @@ import { ETH_SYMBOL, BUYABLE_CHAINS_MAP, } from '../../../shared/constants/network'; -import { TRANSAK_API_KEY } from '../constants/on-ramp'; +import { TRANSAK_API_KEY, MOONPAY_API_KEY } from '../constants/on-ramp'; import { SWAPS_API_V2_BASE_URL } from '../../../shared/constants/swaps'; import getBuyUrl from './buy-url'; @@ -114,4 +114,35 @@ describe('buy-url', () => { const kovanUrl = await getBuyUrl(KOVAN); expect(kovanUrl).toStrictEqual('https://github.com/kovan-testnet/faucet'); }); + + it('returns a MoonPay url with a prefilled wallet address for the Ethereum network', async () => { + const { + moonPay: { defaultCurrencyCode, showOnlyCurrencies } = {}, + } = BUYABLE_CHAINS_MAP[MAINNET.chainId]; + const moonPayQueryParams = new URLSearchParams({ + apiKey: MOONPAY_API_KEY, + walletAddress: MAINNET.address, + defaultCurrencyCode, + showOnlyCurrencies, + }); + const queryParams = new URLSearchParams({ + url: `https://buy.moonpay.com?${moonPayQueryParams}`, + context: 'extension', + }); + nock(SWAPS_API_V2_BASE_URL) + .get(`/moonpaySign/?${queryParams}`) + .reply(200, { + url: `https://buy.moonpay.com/?apiKey=${MOONPAY_API_KEY}&walletAddress=${MAINNET.address}&defaultCurrencyCode=${defaultCurrencyCode}&showOnlyCurrencies=eth%2Cusdt%2Cusdc%2Cdai&signature=laefTlgkESEc2hv8AZEH9F25VjLEJUADY27D6MccE54%3D`, + }); + const moonPayUrl = await getBuyUrl({ ...MAINNET, service: 'moonpay' }); + expect(moonPayUrl).toStrictEqual( + `https://buy.moonpay.com/?apiKey=${MOONPAY_API_KEY}&walletAddress=${MAINNET.address}&defaultCurrencyCode=${defaultCurrencyCode}&showOnlyCurrencies=eth%2Cusdt%2Cusdc%2Cdai&signature=laefTlgkESEc2hv8AZEH9F25VjLEJUADY27D6MccE54%3D`, + ); + nock.cleanAll(); + }); + + it('returns an empty string if generating a MoonPay url fails', async () => { + const moonPayUrl = await getBuyUrl({ ...MAINNET, service: 'moonpay' }); + expect(moonPayUrl).toStrictEqual(''); + }); }); diff --git a/shared/constants/network.js b/shared/constants/network.js index 5cf95da90..844533a0c 100644 --- a/shared/constants/network.js +++ b/shared/constants/network.js @@ -185,11 +185,16 @@ export const IPFS_DEFAULT_GATEWAY_URL = 'dweb.link'; // The first item in transakCurrencies must be the // default crypto currency for the network const BUYABLE_CHAIN_ETHEREUM_NETWORK_NAME = 'ethereum'; + export const BUYABLE_CHAINS_MAP = { [MAINNET_CHAIN_ID]: { nativeCurrency: ETH_SYMBOL, network: BUYABLE_CHAIN_ETHEREUM_NETWORK_NAME, transakCurrencies: [ETH_SYMBOL, 'USDT', 'USDC', 'DAI'], + moonPay: { + defaultCurrencyCode: 'eth', + showOnlyCurrencies: 'eth,usdt,usdc,dai', + }, }, [ROPSTEN_CHAIN_ID]: { nativeCurrency: ETH_SYMBOL, @@ -211,16 +216,28 @@ export const BUYABLE_CHAINS_MAP = { nativeCurrency: BNB_SYMBOL, network: 'bsc', transakCurrencies: [BNB_SYMBOL, 'BUSD'], + moonPay: { + defaultCurrencyCode: 'bnb_bsc', + showOnlyCurrencies: 'bnb_bsc,busd_bsc', + }, }, [POLYGON_CHAIN_ID]: { nativeCurrency: MATIC_SYMBOL, network: 'polygon', transakCurrencies: [MATIC_SYMBOL, 'USDT', 'USDC', 'DAI'], + moonPay: { + defaultCurrencyCode: 'matic_polygon', + showOnlyCurrencies: 'matic_polygon,usdc_polygon', + }, }, [AVALANCHE_CHAIN_ID]: { nativeCurrency: AVALANCHE_SYMBOL, network: 'avaxcchain', transakCurrencies: [AVALANCHE_SYMBOL], + moonPay: { + defaultCurrencyCode: 'avax_cchain', + showOnlyCurrencies: 'avax_cchain', + }, }, [FANTOM_CHAIN_ID]: { nativeCurrency: FANTOM_SYMBOL, @@ -231,5 +248,9 @@ export const BUYABLE_CHAINS_MAP = { nativeCurrency: CELO_SYMBOL, network: 'celo', transakCurrencies: [CELO_SYMBOL], + moonPay: { + defaultCurrencyCode: 'celo', + showOnlyCurrencies: 'celo', + }, }, }; diff --git a/shared/constants/swaps.js b/shared/constants/swaps.js index 2b035bd12..c9b183f7a 100644 --- a/shared/constants/swaps.js +++ b/shared/constants/swaps.js @@ -101,7 +101,7 @@ export const WAVAX_CONTRACT_ADDRESS = const SWAPS_TESTNET_CHAIN_ID = '0x539'; -export const SWAPS_API_V2_BASE_URL = 'https://api2.metaswap.codefi.network'; +export const SWAPS_API_V2_BASE_URL = 'https://swap.metaswap.codefi.network'; export const SWAPS_DEV_API_V2_BASE_URL = 'https://swap.metaswap-dev.codefi.network'; export const GAS_API_BASE_URL = 'https://gas-api.metaswap.codefi.network'; diff --git a/test/e2e/webdriver/index.js b/test/e2e/webdriver/index.js index fee966d8f..9753c03fe 100644 --- a/test/e2e/webdriver/index.js +++ b/test/e2e/webdriver/index.js @@ -48,7 +48,7 @@ async function setupFetchMocking(driver) { return { json: async () => clone(mockResponses.gasPricesBasic) }; } else if (url.match(/chromeextensionmm/u)) { return { json: async () => clone(mockResponses.metametrics) }; - } else if (url.match(/^https:\/\/(api2\.metaswap\.codefi\.network)/u)) { + } else if (url.match(/^https:\/\/(swap\.metaswap\.codefi\.network)/u)) { if (url.match(/featureFlags$/u)) { return { json: async () => clone(mockResponses.swaps.featureFlags) }; } diff --git a/test/jest/constants.js b/test/jest/constants.js index 8015a8a65..4017903d8 100644 --- a/test/jest/constants.js +++ b/test/jest/constants.js @@ -1,2 +1,2 @@ -export const METASWAP_BASE_URL = 'https://api2.metaswap.codefi.network'; +export const METASWAP_BASE_URL = 'https://swap.metaswap.codefi.network'; export const GAS_API_URL = 'https://gas-api.metaswap.codefi.network'; diff --git a/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js b/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js index 518e3b90a..9eb6940d8 100644 --- a/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js +++ b/ui/components/app/modals/deposit-ether-modal/deposit-ether-modal.component.js @@ -5,6 +5,7 @@ import { BUYABLE_CHAINS_MAP, } from '../../../../../shared/constants/network'; import Button from '../../../ui/button'; +import LogoMoonPay from '../../../ui/logo/logo-moonpay'; export default class DepositEtherModal extends Component { static contextTypes = { @@ -17,8 +18,10 @@ export default class DepositEtherModal extends Component { isTestnet: PropTypes.bool.isRequired, isMainnet: PropTypes.bool.isRequired, isBuyableTransakChain: PropTypes.bool.isRequired, + isBuyableMoonPayChain: PropTypes.bool.isRequired, toWyre: PropTypes.func.isRequired, toTransak: PropTypes.func.isRequired, + toMoonPay: PropTypes.func.isRequired, address: PropTypes.string.isRequired, toFaucet: PropTypes.func.isRequired, hideWarning: PropTypes.func.isRequired, @@ -93,11 +96,13 @@ export default class DepositEtherModal extends Component { chainId, toWyre, toTransak, + toMoonPay, address, toFaucet, isTestnet, isMainnet, isBuyableTransakChain, + isBuyableMoonPayChain, } = this.props; const { t } = this.context; const networkName = NETWORK_TO_NAME_MAP[chainId]; @@ -122,31 +127,6 @@ export default class DepositEtherModal extends Component {
- {this.renderRow({ - logo: ( -
- ), - title: t('buyWithWyre'), - text: t('buyWithWyreDescription'), - buttonLabel: t('continueToWyre'), - onButtonClick: () => { - this.context.metricsEvent({ - eventOpts: { - category: 'Accounts', - action: 'Deposit Ether', - name: 'Click buy Ether via Wyre', - }, - }); - toWyre(address); - }, - hide: !isMainnet, - })} {this.renderRow({ logo: (
+ ), + title: t('buyCryptoWithMoonPay', [symbol]), + text: t('buyCryptoWithMoonPayDescription', [symbol]), + buttonLabel: t('continueToMoonPay'), + onButtonClick: () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Deposit tokens', + name: 'Click buy tokens via MoonPay', + }, + }); + toMoonPay(address, chainId); + }, + hide: !isBuyableMoonPayChain, + })} + {this.renderRow({ + logo: ( +
+ ), + title: t('buyWithWyre'), + text: t('buyWithWyreDescription'), + buttonLabel: t('continueToWyre'), + onButtonClick: () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Deposit Ether', + name: 'Click buy Ether via Wyre', + }, + }); + toWyre(address); + }, + hide: !isMainnet, + })} {this.renderRow({ logo: ( { dispatch(buyEth({ service: 'transak', address, chainId })); }, + toMoonPay: (address, chainId) => { + dispatch(buyEth({ service: 'moonpay', address, chainId })); + }, hideModal: () => { dispatch(hideModal()); }, diff --git a/ui/components/app/modals/deposit-ether-modal/index.scss b/ui/components/app/modals/deposit-ether-modal/index.scss index 74e534d13..18009d267 100644 --- a/ui/components/app/modals/deposit-ether-modal/index.scss +++ b/ui/components/app/modals/deposit-ether-modal/index.scss @@ -26,6 +26,10 @@ display: flex; justify-content: center; align-items: center; + + &--moonpay { + height: 30px; + } } &__buy-row { diff --git a/ui/components/ui/logo/__snapshots__/logo-moonpay.test.js.snap b/ui/components/ui/logo/__snapshots__/logo-moonpay.test.js.snap new file mode 100644 index 000000000..4c8e21872 --- /dev/null +++ b/ui/components/ui/logo/__snapshots__/logo-moonpay.test.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LogoMoonPay renders the LogoMoonPay component 1`] = ` +
+ + + + +
+`; diff --git a/ui/components/ui/logo/logo-moonpay.js b/ui/components/ui/logo/logo-moonpay.js new file mode 100644 index 000000000..ea7b4ffc8 --- /dev/null +++ b/ui/components/ui/logo/logo-moonpay.js @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const COLOR_MOONPAY_CIRCLES = '#7D00FF'; + +const LogoMoonPay = ({ + className, + size, + color = 'var(--color-text-default)', +}) => { + return ( + + + + + ); +}; + +LogoMoonPay.propTypes = { + /** + * Additional className to add to the root svg + */ + className: PropTypes.string.isRequired, + /** + * The color of the text of the logo accepts any valid css value + */ + color: PropTypes.string, + /** + * The width of the logo accepts any valid css value + */ + size: PropTypes.string, +}; + +export default LogoMoonPay; diff --git a/ui/components/ui/logo/logo-moonpay.test.js b/ui/components/ui/logo/logo-moonpay.test.js new file mode 100644 index 000000000..afaabd486 --- /dev/null +++ b/ui/components/ui/logo/logo-moonpay.test.js @@ -0,0 +1,11 @@ +import React from 'react'; + +import { renderWithProvider } from '../../../../test/jest'; +import LogoMoonPay from './logo-moonpay'; + +describe('LogoMoonPay', () => { + it('renders the LogoMoonPay component', () => { + const { container } = renderWithProvider(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/ducks/swaps/swaps.test.js b/ui/ducks/swaps/swaps.test.js index 657ac1dab..bf5454918 100644 --- a/ui/ducks/swaps/swaps.test.js +++ b/ui/ducks/swaps/swaps.test.js @@ -34,7 +34,7 @@ describe('Ducks - Swaps', () => { describe('fetchSwapsLivenessAndFeatureFlags', () => { const cleanFeatureFlagApiCache = () => { setStorageItem( - 'cachedFetch:https://api2.metaswap.codefi.network/featureFlags', + 'cachedFetch:https://swap.metaswap.codefi.network/featureFlags', null, ); }; @@ -47,7 +47,7 @@ describe('Ducks - Swaps', () => { featureFlagsResponse, replyWithError = false, } = {}) => { - const apiNock = nock('https://api2.metaswap.codefi.network').get( + const apiNock = nock('https://swap.metaswap.codefi.network').get( '/featureFlags', ); if (replyWithError) { diff --git a/ui/pages/swaps/swaps.util.test.js b/ui/pages/swaps/swaps.util.test.js index 62c140435..0aaa2345f 100644 --- a/ui/pages/swaps/swaps.util.test.js +++ b/ui/pages/swaps/swaps.util.test.js @@ -94,7 +94,7 @@ describe('Swaps Util', () => { }, }; it('should fetch trade info on prod', async () => { - nock('https://api2.metaswap.codefi.network') + nock('https://swap.metaswap.codefi.network') .get('/networks/1/trades') .query(true) .reply(200, MOCK_TRADE_RESPONSE_2); @@ -120,7 +120,7 @@ describe('Swaps Util', () => { describe('fetchTokens', () => { beforeAll(() => { - nock('https://api2.metaswap.codefi.network') + nock('https://swap.metaswap.codefi.network') .persist() .get('/networks/1/tokens') .reply(200, TOKENS); @@ -139,7 +139,7 @@ describe('Swaps Util', () => { describe('fetchAggregatorMetadata', () => { beforeAll(() => { - nock('https://api2.metaswap.codefi.network') + nock('https://swap.metaswap.codefi.network') .persist() .get('/networks/1/aggregatorMetadata') .reply(200, AGGREGATOR_METADATA); @@ -158,7 +158,7 @@ describe('Swaps Util', () => { describe('fetchTopAssets', () => { beforeAll(() => { - nock('https://api2.metaswap.codefi.network') + nock('https://swap.metaswap.codefi.network') .persist() .get('/networks/1/topAssets') .reply(200, TOP_ASSETS); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 29b904e01..40daed3b7 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -672,6 +672,11 @@ export function getIsBuyableTransakChain(state) { return Boolean(BUYABLE_CHAINS_MAP?.[chainId]?.transakCurrencies); } +export function getIsBuyableMoonPayChain(state) { + const chainId = getCurrentChainId(state); + return Boolean(BUYABLE_CHAINS_MAP?.[chainId]?.moonPay); +} + export function getNativeCurrencyImage(state) { const nativeCurrency = getNativeCurrency(state).toUpperCase(); return NATIVE_CURRENCY_TOKEN_IMAGE_MAP[nativeCurrency]; diff --git a/ui/store/actions.js b/ui/store/actions.js index 6461f29da..f6ec05595 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -2102,10 +2102,12 @@ export function showSendTokenPage() { export function buyEth(opts) { return async (dispatch) => { const url = await getBuyUrl(opts); - global.platform.openTab({ url }); - dispatch({ - type: actionConstants.BUY_ETH, - }); + if (url) { + global.platform.openTab({ url }); + dispatch({ + type: actionConstants.BUY_ETH, + }); + } }; }