Fix #9872 - Show price difference warning on swaps price quote (#9899)

feature/default_network_editable
David Walsh 4 years ago committed by GitHub
parent ba98edf604
commit 673371d013
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      app/_locales/en/messages.json
  2. 1
      ui/app/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js
  3. 4
      ui/app/pages/swaps/swaps-footer/swaps-footer.js
  4. 54
      ui/app/pages/swaps/view-quote/index.scss
  5. 149
      ui/app/pages/swaps/view-quote/tests/view-quote-price-difference.test.js
  6. 114
      ui/app/pages/swaps/view-quote/view-quote-price-difference.js
  7. 34
      ui/app/pages/swaps/view-quote/view-quote.js

@ -1741,6 +1741,20 @@
"message": "Your $1 will be added to your account once this transaction has processed.", "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." "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": { "swapProcessing": {
"message": "Processing" "message": "Processing"
}, },

@ -19,7 +19,6 @@ export default function UserPreferencedCurrencyDisplay({
fiatNumberOfDecimals, fiatNumberOfDecimals,
numberOfDecimals: propsNumberOfDecimals, numberOfDecimals: propsNumberOfDecimals,
}) })
const prefixComponent = useMemo(() => { const prefixComponent = useMemo(() => {
return ( return (
currency === ETH && currency === ETH &&

@ -13,13 +13,14 @@ export default function SwapsFooter({
disabled, disabled,
showTermsOfService, showTermsOfService,
showTopBorder, showTopBorder,
className = '',
}) { }) {
const t = useContext(I18nContext) const t = useContext(I18nContext)
return ( return (
<div className="swaps-footer"> <div className="swaps-footer">
<div <div
className={classnames('swaps-footer__buttons', { className={classnames('swaps-footer__buttons', className, {
'swaps-footer__buttons--border': showTopBorder, 'swaps-footer__buttons--border': showTopBorder,
})} })}
> >
@ -62,4 +63,5 @@ SwapsFooter.propTypes = {
disabled: PropTypes.bool, disabled: PropTypes.bool,
showTermsOfService: PropTypes.bool, showTermsOfService: PropTypes.bool,
showTopBorder: PropTypes.bool, showTopBorder: PropTypes.bool,
className: PropTypes.string,
} }

@ -86,14 +86,60 @@
}; };
} }
&__insufficient-eth-warning-wrapper { &__price-difference-warning {
margin-top: 8px; &-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%; width: 100%;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 8px;
@media screen and (min-width: 576px) { @media screen and (min-width: 576px) {
&--thin {
min-height: 36px; min-height: 36px;
}
display: flex; display: flex;
} }
} }
@ -165,4 +211,8 @@
&__metamask-rate-info-icon { &__metamask-rate-info-icon {
margin-left: 4px; margin-left: 4px;
} }
&__thin-swaps-footer {
max-height: 82px;
}
} }

@ -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(
<Provider store={store}>
<ViewQuotePriceDifference {...props} />
</Provider>,
{
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)
})
})

@ -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 (
<div
className={classnames(
'view-quote__price-difference-warning-wrapper',
priceDifferenceClass,
)}
>
<ActionableMessage
message={
<div className="view-quote__price-difference-warning-contents">
<div className="view-quote__price-difference-warning-contents-text">
{priceDifferenceTitle && (
<div className="view-quote__price-difference-warning-contents-title">
{priceDifferenceTitle}
</div>
)}
{priceDifferenceMessage}
</div>
<Tooltip
position="bottom"
theme="white"
title={t('swapPriceDifferenceTooltip')}
>
<i className="fa fa-info-circle" />
</Tooltip>
</div>
}
/>
</div>
)
}
ViewQuotePriceDifference.propTypes = {
usedQuote: PropTypes.object,
sourceTokenValue: PropTypes.string,
destinationTokenValue: PropTypes.string,
}

@ -74,6 +74,7 @@ import { QUOTES_EXPIRED_ERROR } from '../../../helpers/constants/swaps'
import CountdownTimer from '../countdown-timer' import CountdownTimer from '../countdown-timer'
import SwapsFooter from '../swaps-footer' import SwapsFooter from '../swaps-footer'
import InfoTooltip from '../../../components/ui/info-tooltip' import InfoTooltip from '../../../components/ui/info-tooltip'
import ViewQuotePriceDifference from './view-quote-price-difference'
export default function ViewQuote() { export default function ViewQuote() {
const history = useHistory() const history = useHistory()
@ -279,7 +280,7 @@ export default function ViewQuote() {
} }
}, [originalApproveAmount, approveAmount]) }, [originalApproveAmount, approveAmount])
const showWarning = const showInsufficientWarning =
(balanceError || tokenBalanceNeeded || ethBalanceNeeded) && !warningHidden (balanceError || tokenBalanceNeeded || ethBalanceNeeded) && !warningHidden
const numberOfQuotes = Object.values(quotes).length const numberOfQuotes = Object.values(quotes).length
@ -452,7 +453,7 @@ export default function ViewQuote() {
</span> </span>
) )
const actionableMessage = t('swapApproveNeedMoreTokens', [ const actionableInsufficientMessage = t('swapApproveNeedMoreTokens', [
<span key="swapApproveNeedMoreTokens-1" className="view-quote__bold"> <span key="swapApproveNeedMoreTokens-1" className="view-quote__bold">
{tokenBalanceNeeded || ethBalanceNeeded} {tokenBalanceNeeded || ethBalanceNeeded}
</span>, </span>,
@ -461,6 +462,17 @@ export default function ViewQuote() {
: 'ETH', : 'ETH',
]) ])
const viewQuotePriceDifferenceComponent = (
<ViewQuotePriceDifference
usedQuote={usedQuote}
sourceTokenValue={sourceTokenValue}
destinationTokenValue={destinationTokenValue}
/>
)
const isShowingWarning =
showInsufficientWarning || viewQuotePriceDifferenceComponent !== null
return ( return (
<div className="view-quote"> <div className="view-quote">
<div className="view-quote__content"> <div className="view-quote__content">
@ -474,17 +486,22 @@ export default function ViewQuote() {
onQuoteDetailsIsOpened={quoteDetailsOpened} onQuoteDetailsIsOpened={quoteDetailsOpened}
/> />
)} )}
<div className="view-quote__insufficient-eth-warning-wrapper"> <div
{showWarning && ( className={classnames('view-quote__warning-wrapper', {
'view-quote__warning-wrapper--thin': !isShowingWarning,
})}
>
{!showInsufficientWarning && viewQuotePriceDifferenceComponent}
{showInsufficientWarning && (
<ActionableMessage <ActionableMessage
message={actionableMessage} message={actionableInsufficientMessage}
onClose={() => setWarningHidden(true)} onClose={() => setWarningHidden(true)}
/> />
)} )}
</div> </div>
<div <div
className={classnames('view-quote__countdown-timer-container', { className={classnames('view-quote__countdown-timer-container', {
'view-quote__countdown-timer-container--thin': showWarning, 'view-quote__countdown-timer-container--thin': isShowingWarning,
})} })}
> >
<CountdownTimer <CountdownTimer
@ -496,7 +513,7 @@ export default function ViewQuote() {
</div> </div>
<div <div
className={classnames('view-quote__main-quote-summary-container', { className={classnames('view-quote__main-quote-summary-container', {
'view-quote__main-quote-summary-container--thin': showWarning, 'view-quote__main-quote-summary-container--thin': isShowingWarning,
})} })}
> >
<MainQuoteSummary <MainQuoteSummary
@ -540,7 +557,7 @@ export default function ViewQuote() {
</div> </div>
<div <div
className={classnames('view-quote__fee-card-container', { className={classnames('view-quote__fee-card-container', {
'view-quote__fee-card-container--thin': showWarning, 'view-quote__fee-card-container--thin': isShowingWarning,
'view-quote__fee-card-container--three-rows': 'view-quote__fee-card-container--three-rows':
approveTxParams && (!balanceError || warningHidden), approveTxParams && (!balanceError || warningHidden),
})} })}
@ -577,6 +594,7 @@ export default function ViewQuote() {
submitText={t('swap')} submitText={t('swap')}
onCancel={async () => await dispatch(navigateBackToBuildQuote(history))} onCancel={async () => await dispatch(navigateBackToBuildQuote(history))}
disabled={balanceError || gasPrice === null || gasPrice === undefined} disabled={balanceError || gasPrice === null || gasPrice === undefined}
className={isShowingWarning && 'view-quote__thin-swaps-footer'}
showTermsOfService showTermsOfService
showTopBorder showTopBorder
/> />

Loading…
Cancel
Save