mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-12-23 09:52:26 +01:00
parent
ba98edf604
commit
673371d013
@ -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"
|
||||
},
|
||||
|
@ -19,7 +19,6 @@ export default function UserPreferencedCurrencyDisplay({
|
||||
fiatNumberOfDecimals,
|
||||
numberOfDecimals: propsNumberOfDecimals,
|
||||
})
|
||||
|
||||
const prefixComponent = useMemo(() => {
|
||||
return (
|
||||
currency === ETH &&
|
||||
|
@ -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,
|
||||
})}
|
||||
>
|
||||
@ -62,4 +63,5 @@ SwapsFooter.propTypes = {
|
||||
disabled: PropTypes.bool,
|
||||
showTermsOfService: PropTypes.bool,
|
||||
showTopBorder: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
ui/app/pages/swaps/view-quote/view-quote-price-difference.js
Normal file
114
ui/app/pages/swaps/view-quote/view-quote-price-difference.js
Normal file
@ -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 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() {
|
||||
</span>
|
||||
)
|
||||
|
||||
const actionableMessage = t('swapApproveNeedMoreTokens', [
|
||||
const actionableInsufficientMessage = t('swapApproveNeedMoreTokens', [
|
||||
<span key="swapApproveNeedMoreTokens-1" className="view-quote__bold">
|
||||
{tokenBalanceNeeded || ethBalanceNeeded}
|
||||
</span>,
|
||||
@ -461,6 +462,17 @@ export default function ViewQuote() {
|
||||
: 'ETH',
|
||||
])
|
||||
|
||||
const viewQuotePriceDifferenceComponent = (
|
||||
<ViewQuotePriceDifference
|
||||
usedQuote={usedQuote}
|
||||
sourceTokenValue={sourceTokenValue}
|
||||
destinationTokenValue={destinationTokenValue}
|
||||
/>
|
||||
)
|
||||
|
||||
const isShowingWarning =
|
||||
showInsufficientWarning || viewQuotePriceDifferenceComponent !== null
|
||||
|
||||
return (
|
||||
<div className="view-quote">
|
||||
<div className="view-quote__content">
|
||||
@ -474,17 +486,22 @@ export default function ViewQuote() {
|
||||
onQuoteDetailsIsOpened={quoteDetailsOpened}
|
||||
/>
|
||||
)}
|
||||
<div className="view-quote__insufficient-eth-warning-wrapper">
|
||||
{showWarning && (
|
||||
<div
|
||||
className={classnames('view-quote__warning-wrapper', {
|
||||
'view-quote__warning-wrapper--thin': !isShowingWarning,
|
||||
})}
|
||||
>
|
||||
{!showInsufficientWarning && viewQuotePriceDifferenceComponent}
|
||||
{showInsufficientWarning && (
|
||||
<ActionableMessage
|
||||
message={actionableMessage}
|
||||
message={actionableInsufficientMessage}
|
||||
onClose={() => setWarningHidden(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={classnames('view-quote__countdown-timer-container', {
|
||||
'view-quote__countdown-timer-container--thin': showWarning,
|
||||
'view-quote__countdown-timer-container--thin': isShowingWarning,
|
||||
})}
|
||||
>
|
||||
<CountdownTimer
|
||||
@ -496,7 +513,7 @@ export default function ViewQuote() {
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
@ -540,7 +557,7 @@ export default function ViewQuote() {
|
||||
</div>
|
||||
<div
|
||||
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':
|
||||
approveTxParams && (!balanceError || warningHidden),
|
||||
})}
|
||||
@ -577,6 +594,7 @@ export default function ViewQuote() {
|
||||
submitText={t('swap')}
|
||||
onCancel={async () => await dispatch(navigateBackToBuildQuote(history))}
|
||||
disabled={balanceError || gasPrice === null || gasPrice === undefined}
|
||||
className={isShowingWarning && 'view-quote__thin-swaps-footer'}
|
||||
showTermsOfService
|
||||
showTopBorder
|
||||
/>
|
||||
|
Loading…
x
Reference in New Issue
Block a user