diff --git a/CHANGELOG.md b/CHANGELOG.md index 44b3466f5da..92ad2654af3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - added: Monad (MON) wallet support - added: Nym (NYM) wallet support - added: opBNB (BNB) support +- added: Price impact warning on swap confirmation with color-coded severity - added: Register SwapKit V3 as a separate exchange plugin - added: Warning card on send scene when Nym mixnet is active and transaction is loading - added: Verify buy tracking values via Moonpay transactions API with mismatch diagnostics diff --git a/eslint.config.mjs b/eslint.config.mjs index dc6e9ef7eaa..c20fb1371c6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -230,7 +230,6 @@ export default [ 'src/components/progress-indicators/StepProgressBar.tsx', 'src/components/rows/CryptoFiatAmountRow.tsx', - 'src/components/rows/CurrencyRow.tsx', 'src/components/rows/EdgeRow.tsx', diff --git a/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap b/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap index 6dbf122dcf9..080b5371a76 100644 --- a/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap +++ b/src/__tests__/scenes/__snapshots__/SwapConfirmationScene.test.tsx.snap @@ -370,7 +370,7 @@ exports[`SwapConfirmationScene should render with loading props 1`] = ` "initialValues": { "transform": [ { - "translateY": -30, + "translateY": -60, }, ], }, @@ -397,6 +397,205 @@ exports[`SwapConfirmationScene should render with loading props 1`] = ` } } nativeID="1" + > + + + + + + + +  + + + High Price Impact + + + + This swap rate is significantly less favorable than the current market rate. + + + + + €0.000000 + + (99.99%) + @@ -1408,7 +1630,7 @@ exports[`SwapConfirmationScene should render with loading props 1`] = ` "reduceMotionV": "system", } } - nativeID="6" + nativeID="7" > - - - - - - - - -  - - - Estimated Quote - - - - The amount above is an estimate. This exchange may result in less funds received than quoted. - - - - = props => { - const { marginRem, nativeAmount, hideBalance, token, tokenId, wallet } = props + const { + marginRem, + nativeAmount, + hideBalance, + rightSubTextExtended, + token, + tokenId, + wallet + } = props const { pluginId } = wallet.currencyInfo const { showTokenNames = false } = SPECIAL_CURRENCY_INFO[pluginId] ?? {} const theme = useTheme() @@ -96,6 +105,7 @@ const CurrencyRowComponent: React.FC = props => { leftSubtext={name} rightText={cryptoText} rightSubText={fiatText} + rightSubTextExtended={rightSubTextExtended} marginRem={marginRem} /> ) diff --git a/src/components/scenes/SwapConfirmationScene.tsx b/src/components/scenes/SwapConfirmationScene.tsx index a85e7974158..04eed5b9982 100644 --- a/src/components/scenes/SwapConfirmationScene.tsx +++ b/src/components/scenes/SwapConfirmationScene.tsx @@ -1,5 +1,5 @@ import { useIsFocused } from '@react-navigation/native' -import { add, div, gt, gte, toFixed } from 'biggystring' +import { add, div, gt, gte, lte, sub, toFixed } from 'biggystring' import type { EdgeSwapQuote, EdgeSwapResult } from 'edge-core-js' import React, { useState } from 'react' import { SectionList, type ViewStyle } from 'react-native' @@ -54,6 +54,8 @@ import { ModalFooter } from '../themed/ModalParts' import { SafeSlider } from '../themed/SafeSlider' import { WalletListSectionHeader } from '../themed/WalletListSectionHeader' +const PRICE_IMPACT_WARNING_THRESHOLD = 0.05 + export interface SwapConfirmationParams { selectedQuote: EdgeSwapQuote quotes: EdgeSwapQuote[] @@ -76,6 +78,8 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const styles = getStyles(theme) const account = useSelector(state => state.core.account) + const defaultIsoFiat = useSelector(state => state.ui.settings.defaultIsoFiat) + const exchangeRates = useSelector(state => state.exchangeRates) const feeFiat = useSelector(state => selectedQuote == null ? '0' @@ -87,6 +91,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { selectedQuote.networkFee.nativeAmount ) ) + const [pending, setPending] = useState(false) const swapRequestOptions = useSwapRequestOptions() @@ -118,6 +123,48 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { const { request } = selectedQuote const { quoteFor } = request + const priceImpact = React.useMemo(() => { + const { fromWallet, fromTokenId, toWallet, toTokenId } = request + + const fromExchangeDenom = getExchangeDenom( + fromWallet.currencyConfig, + fromTokenId + ) + const toExchangeDenom = getExchangeDenom(toWallet.currencyConfig, toTokenId) + + const fromExchangeAmount = convertNativeToExchange( + fromExchangeDenom.multiplier + )(selectedQuote.fromNativeAmount) + const toExchangeAmount = convertNativeToExchange( + toExchangeDenom.multiplier + )(selectedQuote.toNativeAmount) + + const fromFiatValue = convertCurrency( + exchangeRates, + fromWallet.currencyInfo.pluginId, + fromTokenId, + defaultIsoFiat, + fromExchangeAmount + ) + const toFiatValue = convertCurrency( + exchangeRates, + toWallet.currencyInfo.pluginId, + toTokenId, + defaultIsoFiat, + toExchangeAmount + ) + + if (lte(fromFiatValue, '0')) return undefined + + const impact = parseFloat( + div(sub(fromFiatValue, toFiatValue), fromFiatValue, 8) + ) + return impact > 0 ? impact : undefined + }, [selectedQuote, exchangeRates, defaultIsoFiat, request]) + + const showPriceImpact = + priceImpact != null && priceImpact >= PRICE_IMPACT_WARNING_THRESHOLD + const scrollPadding = React.useMemo( () => ({ paddingBottom: theme.rem(ModalFooter.bottomRem) @@ -243,7 +290,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { expirationDate != null ? expirationDate.toISOString() : 'no expiration' } networkFee: - currencyCode ${networkFee.currencyCode} + tokenId ${String(networkFee.tokenId)} nativeAmount ${networkFee.nativeAmount} `) @@ -295,7 +342,7 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { } } - const renderTimer = (): React.ReactNode => { + const renderTimer = (): React.ReactElement | null => { const { expirationDate } = selectedQuote if (expirationDate == null) return null return ( @@ -383,7 +430,23 @@ export const SwapConfirmationScene: React.FC = (props: Props) => { return ( - {showFeeWarning ? ( + {showPriceImpact && showFeeWarning ? ( + + + + ) : showPriceImpact ? ( + + + + ) : showFeeWarning ? ( = (props: Props) => { - + = (props: Props) => { onPress={handlePoweredByTap} /> - {selectedQuote.isEstimate ? ( + {selectedQuote.isEstimate && !showPriceImpact ? ( = props => { - const { fromTo, quote, showFeeWarning } = props + const { fromTo, priceImpact, quote, showFeeWarning } = props const { request, fromNativeAmount, toNativeAmount, networkFee } = quote const { fromWallet, fromTokenId, toWallet, toTokenId } = request @@ -130,7 +132,7 @@ export const ExchangeQuote: React.FC = props => { label: React.ReactNode, value: React.ReactNode, style: any = {} - ): React.ReactNode => { + ): React.ReactElement => { return ( {label} @@ -139,7 +141,7 @@ export const ExchangeQuote: React.FC = props => { ) } - const renderBottom = (): React.ReactNode => { + const renderBottom = (): React.ReactElement | null => { if (fromTo === 'from') { const feeTextStyle = showFeeWarning === true ? styles.bottomWarningText : styles.bottomText @@ -184,6 +186,21 @@ export const ExchangeQuote: React.FC = props => { } } + const priceImpactNode = + !isFrom && priceImpact != null && priceImpact > 0 ? ( + = 0.15 + ? styles.priceImpactHigh + : priceImpact >= 0.05 + ? styles.priceImpactMedium + : styles.priceImpactLow + } + > + {` (${formatNumber(priceImpact * 100, { toFixed: 2 })}%)`} + + ) : undefined + return ( = props => { marginRem={0.5} nativeAmount={nativeAmount} hideBalance={false} + rightSubTextExtended={priceImpactNode} /> {renderBottom()} @@ -223,5 +241,17 @@ const getStyles = cacheStyles((theme: Theme) => ({ bottomWarningText: { fontSize: theme.rem(0.75), color: theme.warningText + }, + priceImpactLow: { + fontSize: theme.rem(0.75), + color: theme.deactivatedText + }, + priceImpactMedium: { + fontSize: theme.rem(0.75), + color: theme.warningText + }, + priceImpactHigh: { + fontSize: theme.rem(0.75), + color: theme.dangerText } })) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 0c324de9d77..9dce7c12c5e 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -547,6 +547,12 @@ const strings = { swap_token_no_enabled_exchanges_2s: 'No enabled exchanges support %1$s (on %2$s) at this time', swap_minimum_receive_amount: 'Min Receive Amount', + swap_price_impact_warning_title: 'High Price Impact', + swap_price_impact_warning_body: + 'This swap rate is significantly less favorable than the current market rate.', + swap_price_impact_fee_warning_title: 'High Price Impact', + swap_price_impact_fee_warning_body: + 'This swap rate is significantly less favorable than the current market rate. High network fees relative to the swap amount are a contributing factor.', swap_minimum_amount_1s: 'Min %1$s', settings_button_clear_logs: 'Clear Logs', send_to_1s: 'Send to %1$s', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index b83e0fe33d2..0406d99057d 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -397,6 +397,10 @@ "swap_preferred_promo_instructions": "When multiple exchanges can fill an order, the current promotion always prefers:", "swap_token_no_enabled_exchanges_2s": "No enabled exchanges support %1$s (on %2$s) at this time", "swap_minimum_receive_amount": "Min Receive Amount", + "swap_price_impact_warning_title": "High Price Impact", + "swap_price_impact_warning_body": "This swap rate is significantly less favorable than the current market rate.", + "swap_price_impact_fee_warning_title": "High Price Impact", + "swap_price_impact_fee_warning_body": "This swap rate is significantly less favorable than the current market rate. High network fees relative to the swap amount are a contributing factor.", "swap_minimum_amount_1s": "Min %1$s", "settings_button_clear_logs": "Clear Logs", "send_to_1s": "Send to %1$s",