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",