diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 8a00369f4..a3d5b5f4c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -1741,6 +1741,20 @@ "message": "Your $1 will be added to your account once this transaction has processed.", "description": "This message communicates the token that is being transferred. It is shown on the awaiting swap screen. The $1 will be a token symbol." }, + "swapPriceDifference": { + "message": "You are about to swap $1 $2 (~$3) for $4 $5 (~$6).", + "description": "This message represents the price slippage for the swap. $1 and $4 are a number (ex: 2.89), $2 and $5 are symbols (ex: ETH), and $3 and $6 are fiat currency amounts." + }, + "swapPriceDifferenceTitle": { + "message": "Price difference of ~$1%", + "description": "$1 is a number (ex: 1.23) that represents the price difference." + }, + "swapPriceDifferenceTooltip": { + "message": "The difference in market prices can be affected by fees taken by intermediaries, size of market, size of trade, or market inefficiencies." + }, + "swapPriceDifferenceUnavailable": { + "message": "Market price is unavailable. Make sure you feel comfortable with the returned amount before proceeding." + }, "swapProcessing": { "message": "Processing" }, diff --git a/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js index 208b93197..8496d7ca1 100644 --- a/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js +++ b/ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js @@ -19,7 +19,6 @@ export default function UserPreferencedCurrencyDisplay({ fiatNumberOfDecimals, numberOfDecimals: propsNumberOfDecimals, }) - const prefixComponent = useMemo(() => { return ( currency === ETH && diff --git a/ui/app/pages/swaps/swaps-footer/swaps-footer.js b/ui/app/pages/swaps/swaps-footer/swaps-footer.js index c158c21fb..870ce028b 100644 --- a/ui/app/pages/swaps/swaps-footer/swaps-footer.js +++ b/ui/app/pages/swaps/swaps-footer/swaps-footer.js @@ -13,13 +13,14 @@ export default function SwapsFooter({ disabled, showTermsOfService, showTopBorder, + className = '', }) { const t = useContext(I18nContext) return (
@@ -62,4 +63,5 @@ SwapsFooter.propTypes = { disabled: PropTypes.bool, showTermsOfService: PropTypes.bool, showTopBorder: PropTypes.bool, + className: PropTypes.string, } diff --git a/ui/app/pages/swaps/view-quote/index.scss b/ui/app/pages/swaps/view-quote/index.scss index 1b76853fb..474eced60 100644 --- a/ui/app/pages/swaps/view-quote/index.scss +++ b/ui/app/pages/swaps/view-quote/index.scss @@ -86,14 +86,60 @@ }; } - &__insufficient-eth-warning-wrapper { - margin-top: 8px; + &__price-difference-warning { + &-wrapper { + width: 100%; + + &.medium .actionable-message, + &.fiat-error .actionable-message { + border-color: $Yellow-500; + background: $Yellow-100; + + .actionable-message__message { + color: inherit; + } + } + + &.high .actionable-message { + border-color: $Red-500; + background: $Red-100; + + .actionable-message__message { + color: $Red-500; + } + } + + /* Hides info tooltip if there's a fiat error message */ + &.fiat-error div[data-tooltipped] { + /* !important overrides style being applied directly to tooltip by component */ + display: none !important; + } + } + + &-contents { + display: flex; + + &-title { + font-weight: bold; + } + + i { + margin-inline-start: 10px; + } + } + } + + &__warning-wrapper { width: 100%; align-items: center; justify-content: center; + margin-top: 8px; @media screen and (min-width: 576px) { - min-height: 36px; + &--thin { + min-height: 36px; + } + display: flex; } } @@ -165,4 +211,8 @@ &__metamask-rate-info-icon { margin-left: 4px; } + + &__thin-swaps-footer { + max-height: 82px; + } } diff --git a/ui/app/pages/swaps/view-quote/tests/view-quote-price-difference.test.js b/ui/app/pages/swaps/view-quote/tests/view-quote-price-difference.test.js new file mode 100644 index 000000000..d32cc5759 --- /dev/null +++ b/ui/app/pages/swaps/view-quote/tests/view-quote-price-difference.test.js @@ -0,0 +1,149 @@ +import assert from 'assert' +import React from 'react' +import { shallow } from 'enzyme' +import { Provider } from 'react-redux' +import configureMockStore from 'redux-mock-store' +import ViewQuotePriceDifference from '../view-quote-price-difference' + +describe('View Price Quote Difference', function () { + const t = (key) => `translate ${key}` + + const state = { + metamask: { + tokens: [], + provider: { type: 'rpc', nickname: '', rpcUrl: '' }, + preferences: { showFiatInTestnets: true }, + currentCurrency: 'usd', + conversionRate: 600.0, + }, + } + + const store = configureMockStore()(state) + + // Sample transaction is 1 $ETH to ~42.880915 $LINK + const DEFAULT_PROPS = { + usedQuote: { + trade: { + data: + '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000007756e69737761700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000514910771af9ca656af840dff83e8264ecf986ca0000000000000000000000000000000000000000000000000dc1a09f859b20000000000000000000000000000000000000000000000000024855454cb32d335f0000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000005fc7b7100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001f161421c8e0000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000514910771af9ca656af840dff83e8264ecf986ca', + from: '0xd7440fdcb70a9fba55dfe06942ddbc17679c90ac', + value: '0xde0b6b3a7640000', + gas: '0xbbfd0', + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + }, + sourceAmount: '1000000000000000000', + destinationAmount: '42947749216634160067', + error: null, + sourceToken: '0x0000000000000000000000000000000000000000', + destinationToken: '0x514910771af9ca656af840dff83e8264ecf986ca', + approvalNeeded: null, + maxGas: 770000, + averageGas: 210546, + estimatedRefund: 80000, + fetchTime: 647, + aggregator: 'uniswap', + aggType: 'DEX', + fee: 0.875, + gasMultiplier: 1.5, + priceSlippage: { + ratio: 1.007876641534847, + calculationError: '', + bucket: 'low', + sourceAmountInETH: 1, + destinationAmountInEth: 0.9921849150875727, + }, + slippage: 2, + sourceTokenInfo: { + symbol: 'ETH', + name: 'Ether', + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + iconUrl: 'images/black-eth-logo.svg', + }, + destinationTokenInfo: { + address: '0x514910771af9ca656af840dff83e8264ecf986ca', + symbol: 'LINK', + decimals: 18, + occurances: 12, + iconUrl: + 'https://cloudflare-ipfs.com/ipfs/QmQhZAdcZvW9T2tPm516yHqbGkfhyZwTZmLixW9MXJudTA', + }, + ethFee: '0.011791', + ethValueOfTokens: '0.99220724791716534441', + overallValueOfQuote: '0.98041624791716534441', + metaMaskFeeInEth: '0.00875844985551091729', + isBestQuote: true, + savings: { + performance: '0.00207907025112527799', + fee: '0.005581', + metaMaskFee: '0.00875844985551091729', + total: '-0.0010983796043856393', + medianMetaMaskFee: '0.00874009740688812165', + }, + }, + sourceTokenValue: '1', + destinationTokenValue: '42.947749', + } + + let component + function renderComponent(props) { + component = shallow( + + + , + { + context: { t }, + }, + ) + } + + afterEach(function () { + component.unmount() + }) + + it('does not render when there is no quote', function () { + const props = { ...DEFAULT_PROPS, usedQuote: null } + renderComponent(props) + + const wrappingDiv = component.find( + '.view-quote__price-difference-warning-wrapper', + ) + assert.strictEqual(wrappingDiv.length, 0) + }) + + it('does not render when the item is in the low bucket', function () { + const props = { ...DEFAULT_PROPS } + props.usedQuote.priceSlippage.bucket = 'low' + + renderComponent(props) + const wrappingDiv = component.find( + '.view-quote__price-difference-warning-wrapper', + ) + assert.strictEqual(wrappingDiv.length, 0) + }) + + it('displays an error when in medium bucket', function () { + const props = { ...DEFAULT_PROPS } + props.usedQuote.priceSlippage.bucket = 'medium' + + renderComponent(props) + assert.strictEqual(component.html().includes('medium'), true) + }) + + it('displays an error when in high bucket', function () { + const props = { ...DEFAULT_PROPS } + props.usedQuote.priceSlippage.bucket = 'high' + + renderComponent(props) + assert.strictEqual(component.html().includes('high'), true) + }) + + it('displays a fiat error when calculationError is present', function () { + const props = { ...DEFAULT_PROPS } + props.usedQuote.priceSlippage.calculationError = + 'Could not determine price.' + + renderComponent(props) + assert.strictEqual(component.html().includes('fiat-error'), true) + }) +}) diff --git a/ui/app/pages/swaps/view-quote/view-quote-price-difference.js b/ui/app/pages/swaps/view-quote/view-quote-price-difference.js new file mode 100644 index 000000000..5a3be3cac --- /dev/null +++ b/ui/app/pages/swaps/view-quote/view-quote-price-difference.js @@ -0,0 +1,114 @@ +import React, { useContext } from 'react' + +import PropTypes from 'prop-types' +import classnames from 'classnames' +import BigNumber from 'bignumber.js' +import { useEthFiatAmount } from '../../../hooks/useEthFiatAmount' +import { I18nContext } from '../../../contexts/i18n' + +import ActionableMessage from '../actionable-message' +import Tooltip from '../../../components/ui/tooltip' + +export default function ViewQuotePriceDifference(props) { + const { usedQuote, sourceTokenValue, destinationTokenValue } = props + + const t = useContext(I18nContext) + + const priceSlippageFromSource = useEthFiatAmount( + usedQuote?.priceSlippage?.sourceAmountInETH || 0, + ) + const priceSlippageFromDestination = useEthFiatAmount( + usedQuote?.priceSlippage?.destinationAmountInEth || 0, + ) + + if (!usedQuote || !usedQuote.priceSlippage) { + return null + } + + const { priceSlippage } = usedQuote + + // We cannot present fiat value if there is a calculation error or no slippage + // from source or destination + const priceSlippageUnknownFiatValue = + !priceSlippageFromSource || + !priceSlippageFromDestination || + priceSlippage.calculationError + + let priceDifferencePercentage = 0 + if (priceSlippage.ratio) { + priceDifferencePercentage = parseFloat( + new BigNumber(priceSlippage.ratio, 10) + .minus(1, 10) + .times(100, 10) + .toFixed(2), + 10, + ) + } + + const shouldShowPriceDifferenceWarning = + ['high', 'medium'].includes(priceSlippage.bucket) || + priceSlippageUnknownFiatValue + + if (!shouldShowPriceDifferenceWarning) { + return null + } + + let priceDifferenceTitle = '' + let priceDifferenceMessage = '' + let priceDifferenceClass = '' + if (priceSlippageUnknownFiatValue) { + // A calculation error signals we cannot determine dollar value + priceDifferenceMessage = t('swapPriceDifferenceUnavailable') + priceDifferenceClass = 'fiat-error' + } else { + priceDifferenceTitle = t('swapPriceDifferenceTitle', [ + priceDifferencePercentage, + ]) + priceDifferenceMessage = t('swapPriceDifference', [ + sourceTokenValue, // Number of source token to swap + usedQuote.sourceTokenInfo.symbol, // Source token symbol + priceSlippageFromSource, // Source tokens total value + destinationTokenValue, // Number of destination tokens in return + usedQuote.destinationTokenInfo.symbol, // Destination token symbol, + priceSlippageFromDestination, // Destination tokens total value + ]) + priceDifferenceClass = priceSlippage.bucket + } + + return ( +
+ +
+ {priceDifferenceTitle && ( +
+ {priceDifferenceTitle} +
+ )} + {priceDifferenceMessage} +
+ + + +
+ } + /> +
+ ) +} + +ViewQuotePriceDifference.propTypes = { + usedQuote: PropTypes.object, + sourceTokenValue: PropTypes.string, + destinationTokenValue: PropTypes.string, +} diff --git a/ui/app/pages/swaps/view-quote/view-quote.js b/ui/app/pages/swaps/view-quote/view-quote.js index 8528a38b3..4db97824f 100644 --- a/ui/app/pages/swaps/view-quote/view-quote.js +++ b/ui/app/pages/swaps/view-quote/view-quote.js @@ -74,6 +74,7 @@ import { QUOTES_EXPIRED_ERROR } from '../../../helpers/constants/swaps' import CountdownTimer from '../countdown-timer' import SwapsFooter from '../swaps-footer' import InfoTooltip from '../../../components/ui/info-tooltip' +import ViewQuotePriceDifference from './view-quote-price-difference' export default function ViewQuote() { const history = useHistory() @@ -279,7 +280,7 @@ export default function ViewQuote() { } }, [originalApproveAmount, approveAmount]) - const showWarning = + const showInsufficientWarning = (balanceError || tokenBalanceNeeded || ethBalanceNeeded) && !warningHidden const numberOfQuotes = Object.values(quotes).length @@ -452,7 +453,7 @@ export default function ViewQuote() { ) - const actionableMessage = t('swapApproveNeedMoreTokens', [ + const actionableInsufficientMessage = t('swapApproveNeedMoreTokens', [ {tokenBalanceNeeded || ethBalanceNeeded} , @@ -461,6 +462,17 @@ export default function ViewQuote() { : 'ETH', ]) + const viewQuotePriceDifferenceComponent = ( + + ) + + const isShowingWarning = + showInsufficientWarning || viewQuotePriceDifferenceComponent !== null + return (
@@ -474,17 +486,22 @@ export default function ViewQuote() { onQuoteDetailsIsOpened={quoteDetailsOpened} /> )} -
- {showWarning && ( +
+ {!showInsufficientWarning && viewQuotePriceDifferenceComponent} + {showInsufficientWarning && ( setWarningHidden(true)} /> )}
await dispatch(navigateBackToBuildQuote(history))} disabled={balanceError || gasPrice === null || gasPrice === undefined} + className={isShowingWarning && 'view-quote__thin-swaps-footer'} showTermsOfService showTopBorder />