Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 12 commits into from
Dec 2, 2020
Merged
14 changes: 14 additions & 0 deletions app/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export default function UserPreferencedCurrencyDisplay({
fiatNumberOfDecimals,
numberOfDecimals: propsNumberOfDecimals,
})

const prefixComponent = useMemo(() => {
return (
currency === ETH &&
Expand Down
4 changes: 3 additions & 1 deletion ui/app/pages/swaps/swaps-footer/swaps-footer.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ export default function SwapsFooter({
disabled,
showTermsOfService,
showTopBorder,
className = '',
}) {
const t = useContext(I18nContext)

return (
<div className="swaps-footer">
<div
className={classnames('swaps-footer__buttons', {
className={classnames('swaps-footer__buttons', className, {
'swaps-footer__buttons--border': showTopBorder,
})}
>
Expand Down Expand Up @@ -62,4 +63,5 @@ SwapsFooter.propTypes = {
disabled: PropTypes.bool,
showTermsOfService: PropTypes.bool,
showTopBorder: PropTypes.bool,
className: PropTypes.string,
}
56 changes: 53 additions & 3 deletions ui/app/pages/swaps/view-quote/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -165,4 +211,8 @@
&__metamask-rate-info-icon {
margin-left: 4px;
}

&__thin-swaps-footer {
max-height: 82px;
}
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
114 changes: 114 additions & 0 deletions ui/app/pages/swaps/view-quote/view-quote-price-difference.js
Original file line number Diff line number Diff line change
@@ -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,
}
Loading