@@ -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', [
,
@@ -461,6 +462,17 @@ export default function ViewQuote() {
: 'ETH',
])
+ const viewQuotePriceDifferenceComponent = (
+
+ )
+
+ const isShowingWarning =
+ showInsufficientWarning || viewQuotePriceDifferenceComponent !== null
+
return (