diff --git a/apps/mobile/assets/icons/sparkle.svg b/apps/mobile/assets/icons/sparkle.svg new file mode 100644 index 000000000..2d5ede8e2 --- /dev/null +++ b/apps/mobile/assets/icons/sparkle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/src/components/core/PWIcon/constants.ts b/apps/mobile/src/components/core/PWIcon/constants.ts index b2ad779fe..5c070a02e 100644 --- a/apps/mobile/src/components/core/PWIcon/constants.ts +++ b/apps/mobile/src/components/core/PWIcon/constants.ts @@ -77,6 +77,7 @@ import ShareIcon from '@assets/icons/share.svg' import ShieldCheckIcon from '@assets/icons/shield-check.svg' import SlidersIcon from '@assets/icons/sliders.svg' import SnowflakeIcon from '@assets/icons/snowflake.svg' +import SparkleIcon from '@assets/icons/sparkle.svg' import StarIcon from '@assets/icons/star.svg' import StarFilledIcon from '@assets/icons/star-filled.svg' import SwapIcon from '@assets/icons/swap.svg' @@ -207,6 +208,7 @@ export const ICON_LIBRARY = { 'shield-check': ShieldCheckIcon, sliders: SlidersIcon, snowflake: SnowflakeIcon, + sparkle: SparkleIcon, star: StarIcon, 'star-filled': StarFilledIcon, swap: SwapIcon, diff --git a/apps/mobile/src/components/core/PWRadioButton/PWRadioButton.tsx b/apps/mobile/src/components/core/PWRadioButton/PWRadioButton.tsx index 024e0d373..6195783b0 100644 --- a/apps/mobile/src/components/core/PWRadioButton/PWRadioButton.tsx +++ b/apps/mobile/src/components/core/PWRadioButton/PWRadioButton.tsx @@ -10,6 +10,8 @@ limitations under the License */ +import { ReactNode } from 'react' +import { StyleProp, ViewStyle } from 'react-native' import { PWText } from '../PWText' import { PWTouchableOpacity } from '../PWTouchableOpacity' import { PWView } from '../PWView' @@ -19,17 +21,21 @@ import { useStyles } from './styles' export type PWRadioButtonProps = { onPress: () => void - title: string + title?: string + children?: ReactNode isSelected: boolean isDisabled?: boolean testID?: string + containerStyle?: StyleProp } export const PWRadioButton = ({ onPress, title, + children, isSelected, isDisabled = false, testID, + containerStyle, }: PWRadioButtonProps) => { const styles = useStyles() @@ -37,15 +43,16 @@ export const PWRadioButton = ({ - {title} + {children ?? {title}} {isSelected && } diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index aa6ee6ed1..bbef6e01e 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -1316,7 +1316,6 @@ "price_impact": "Price Impact", "slippage_tolerance": "Slippage Tolerance", "pera_fee": "Pera Fee", - "exchange_fee": "Exchange Fee", "provider": "Provider", "minimum_received": "Minimum Received", "review_swap": "Review Swap", @@ -1340,8 +1339,15 @@ }, "info": { "slippage_tolerance": "The maximum difference between the expected price and the execution price. A higher tolerance increases the chance of a successful swap but may result in a less favorable rate.", - "price_impact": "The difference between the market price and the estimated price due to trade size. Larger trades typically have a higher price impact.", - "exchange_fee": "The fee charged by the decentralized exchange for facilitating this swap." + "price_impact": "The difference between the market price and the estimated price due to trade size. Larger trades typically have a higher price impact." + }, + "provider": { + "title_auto": "Provider (Best price available)", + "title_manual": "Provider", + "change_title": "Change Provider", + "apply": "Apply", + "auto_label": "Auto", + "auto_description": "Best price available" } }, "ledger": { diff --git a/apps/mobile/src/modules/swap/components/SwapAmountSection/SwapAmountSection.tsx b/apps/mobile/src/modules/swap/components/SwapAmountSection/SwapAmountSection.tsx index 4ca2f4649..3209b0985 100644 --- a/apps/mobile/src/modules/swap/components/SwapAmountSection/SwapAmountSection.tsx +++ b/apps/mobile/src/modules/swap/components/SwapAmountSection/SwapAmountSection.tsx @@ -113,7 +113,6 @@ export const SwapAmountSection = (props: SwapAmountSectionProps) => { ? styles.amountText : styles.amountTextMuted } - variant='h2' testID='swap-receive-amount' > {displayValue || '0.00'} diff --git a/apps/mobile/src/modules/swap/components/SwapAmountSection/styles.ts b/apps/mobile/src/modules/swap/components/SwapAmountSection/styles.ts index 0e67ca112..2aa4508d6 100644 --- a/apps/mobile/src/modules/swap/components/SwapAmountSection/styles.ts +++ b/apps/mobile/src/modules/swap/components/SwapAmountSection/styles.ts @@ -11,6 +11,7 @@ */ import { makeStyles } from '@rneui/themed' +import { getTypography } from '@theme/typography' export const useStyles = makeStyles(theme => ({ container: { @@ -46,10 +47,9 @@ export const useStyles = makeStyles(theme => ({ amountContainer: { flex: 1, }, - amountText: { - color: theme.colors.textMain, - }, + amountText: getTypography(theme, 'h2'), amountTextMuted: { + ...getTypography(theme, 'h2'), color: theme.colors.textGrayLighter, }, amountInputContainer: { diff --git a/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapConfirmationBottomSheet.tsx b/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapConfirmationBottomSheet.tsx index 40f621743..898fb27d6 100644 --- a/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapConfirmationBottomSheet.tsx +++ b/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapConfirmationBottomSheet.tsx @@ -58,7 +58,6 @@ export const SwapConfirmationBottomSheet = ({ rateDisplay, minimumReceivedDisplay, peraFeeDisplay, - exchangeFeeDisplay, hasHighPriceImpact, priceImpactDisplay, priceImpactStyle, @@ -136,7 +135,6 @@ export const SwapConfirmationBottomSheet = ({ rateDisplay={rateDisplay} minimumReceivedDisplay={minimumReceivedDisplay} peraFeeDisplay={peraFeeDisplay} - exchangeFeeDisplay={exchangeFeeDisplay} priceImpactDisplay={priceImpactDisplay} priceImpactStyle={priceImpactStyle} /> diff --git a/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapDetailsSection.tsx b/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapDetailsSection.tsx index 0295992c6..0f98bcd89 100644 --- a/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapDetailsSection.tsx +++ b/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/SwapDetailsSection.tsx @@ -22,7 +22,6 @@ type SwapDetailsSectionProps = { rateDisplay: string minimumReceivedDisplay: string peraFeeDisplay: string - exchangeFeeDisplay: string priceImpactDisplay: string priceImpactStyle: object } @@ -32,7 +31,6 @@ export const SwapDetailsSection = ({ rateDisplay, minimumReceivedDisplay, peraFeeDisplay, - exchangeFeeDisplay, priceImpactDisplay, priceImpactStyle, }: SwapDetailsSectionProps) => { @@ -75,12 +73,6 @@ export const SwapDetailsSection = ({ value={minimumReceivedDisplay} valueStyle={styles.detailValue} /> - = {}): SwapQuote => ({ priceImpact: new Decimal('0.5'), slippage: new Decimal('0.5'), peraFeeAmount: new Decimal('1000'), - exchangeFeeAmount: new Decimal('2000'), provider: 'tinyman', providerDisplayName: 'Tinyman', ...overrides, @@ -231,7 +230,6 @@ describe('SwapConfirmationBottomSheet', () => { expect(screen.getByText('swap.quote.slippage_tolerance')).toBeDefined() expect(screen.getByText('swap.quote.price_impact')).toBeDefined() expect(screen.getByText('swap.quote.minimum_received')).toBeDefined() - expect(screen.getByText('swap.quote.exchange_fee')).toBeDefined() expect(screen.getByText('swap.quote.pera_fee')).toBeDefined() }) diff --git a/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/useSwapConfirmation.ts b/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/useSwapConfirmation.ts index 142f74540..6b8bca1c4 100644 --- a/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/useSwapConfirmation.ts +++ b/apps/mobile/src/modules/swap/components/SwapConfirmationBottomSheet/useSwapConfirmation.ts @@ -43,7 +43,6 @@ type UseSwapConfirmationResult = { rateDisplay: string minimumReceivedDisplay: string peraFeeDisplay: string - exchangeFeeDisplay: string hasHighPriceImpact: boolean priceImpactDisplay: string priceImpactStyle: object @@ -118,14 +117,6 @@ export const useSwapConfirmation = ({ return formatAssetAmount(quote.peraFeeAmount, quote.assetIn) }, [quote?.peraFeeAmount, quote?.assetIn]) - const exchangeFeeDisplay = useMemo(() => { - if (!quote?.exchangeFeeAmount) return '-' - if (quote.exchangeFeeAmount.isZero()) { - return `0 ${quote.assetIn.unitName ?? ''}`.trim() - } - return formatAssetAmount(quote.exchangeFeeAmount, quote.assetIn) - }, [quote?.exchangeFeeAmount, quote?.assetIn]) - const hasHighPriceImpact = useMemo( () => quote?.priceImpact?.greaterThanOrEqualTo( @@ -159,7 +150,6 @@ export const useSwapConfirmation = ({ rateDisplay, minimumReceivedDisplay, peraFeeDisplay, - exchangeFeeDisplay, hasHighPriceImpact, priceImpactDisplay, priceImpactStyle, diff --git a/apps/mobile/src/modules/swap/components/SwapForm/SwapForm.tsx b/apps/mobile/src/modules/swap/components/SwapForm/SwapForm.tsx index 04e4b266c..97eac9496 100644 --- a/apps/mobile/src/modules/swap/components/SwapForm/SwapForm.tsx +++ b/apps/mobile/src/modules/swap/components/SwapForm/SwapForm.tsx @@ -16,6 +16,8 @@ import { SwapAssetSelectionBottomSheet } from '../SwapAssetSelectionBottomSheet' import { SwapAmountSection } from '../SwapAmountSection' import { SwapConfigurationBottomSheet } from '../SwapConfigurationBottomSheet' import { SwapConfirmationBottomSheet } from '../SwapConfirmationBottomSheet' +import { SwapProviderBottomSheet } from '../SwapProviderBottomSheet' +import { SwapProviderRow } from '../SwapProviderRow' import { SwapFormControls } from './SwapFormControls' import { useSwapForm } from './useSwapForm' import { useStyles } from './styles' @@ -33,18 +35,23 @@ export const SwapForm = () => { isQuoteFetching, isQuoteError, selectedQuote, + allQuotes, + selectedProviderName, + providerSelectionMode, canSwap, swapStatus, payAssetModal, receiveAssetModal, configModal, confirmModal, + providerModal, handlePayAmountChange, handleSwapDirection, handleMaxPress, handlePayAssetSelected, handleReceiveAssetSelected, handleConfigApply, + handleProviderApply, handleConfirmSwap, handleOpenConfirm, handleCloseConfirm, @@ -79,6 +86,14 @@ export const SwapForm = () => { /> + {selectedQuote && ( + + )} + {isQuoteError && ( { )} - + {selectedQuote && ( + + )} { quote={selectedQuote} swapStatus={swapStatus} /> + + ) } diff --git a/apps/mobile/src/modules/swap/components/SwapForm/styles.ts b/apps/mobile/src/modules/swap/components/SwapForm/styles.ts index a826a1a8a..863f354e8 100644 --- a/apps/mobile/src/modules/swap/components/SwapForm/styles.ts +++ b/apps/mobile/src/modules/swap/components/SwapForm/styles.ts @@ -69,7 +69,7 @@ export const useStyles = makeStyles(theme => ({ color: theme.colors.linkPrimary, }, swapButton: { - marginTop: theme.spacing.lg, + marginTop: theme.spacing.sm, }, errorContainer: { paddingHorizontal: theme.spacing.lg, diff --git a/apps/mobile/src/modules/swap/components/SwapForm/useSwapForm.ts b/apps/mobile/src/modules/swap/components/SwapForm/useSwapForm.ts index 275163ae8..e280e06d9 100644 --- a/apps/mobile/src/modules/swap/components/SwapForm/useSwapForm.ts +++ b/apps/mobile/src/modules/swap/components/SwapForm/useSwapForm.ts @@ -43,6 +43,7 @@ import { useSwapExecution, type SwapExecutionStatus, } from '../../hooks/useSwapExecution' +import { pickBestByAmountOut } from '../../hooks/swapQuoteHelpers' type ModalState = ReturnType @@ -56,18 +57,23 @@ type UseSwapFormResult = { isQuoteFetching: boolean isQuoteError: boolean selectedQuote: SwapQuote | null + allQuotes: SwapQuote[] + selectedProviderName: string | null + providerSelectionMode: 'auto' | 'manual' canSwap: boolean swapStatus: SwapExecutionStatus payAssetModal: ModalState receiveAssetModal: ModalState configModal: ModalState confirmModal: ModalState + providerModal: ModalState handlePayAmountChange: (amount: Decimal | null) => void handleSwapDirection: () => void handleMaxPress: () => void handlePayAssetSelected: (asset: AssetWithAccountBalance) => void handleReceiveAssetSelected: (asset: AssetWithAccountBalance) => void handleConfigApply: (result: SwapConfigurationResult) => void + handleProviderApply: (providerName: string | null) => void handleConfirmSwap: () => void handleOpenConfirm: () => void handleCloseConfirm: () => void @@ -90,11 +96,15 @@ export const useSwapForm = (): UseSwapFormResult => { useCurrency() const [payAmount, setPayAmount] = useState(null) const [receiveAmount, setReceiveAmount] = useState(null) - const [selectedQuote, setSelectedQuote] = useState(null) + const [allQuotes, setAllQuotes] = useState([]) + const [selectedProviderName, setSelectedProviderName] = useState< + string | null + >(null) const payAssetModal = useModalState() const receiveAssetModal = useModalState() const configModal = useModalState() const confirmModal = useModalState() + const providerModal = useModalState() const selectedAccount = useSelectedAccount() const deviceId = useDeviceID(network) const prefetchProviders = usePrefetchProviders() @@ -162,8 +172,7 @@ export const useSwapForm = (): UseSwapFormResult => { debouncedPayAmount.isZero() || debouncedPayAmount.isNeg() ) { - setReceiveAmount(null) - setSelectedQuote(null) + setAllQuotes([]) return } @@ -188,31 +197,10 @@ export const useSwapForm = (): UseSwapFormResult => { if (cancelled) return - const best = result.reduce((prev, curr) => { - if (!curr.amountOut) return prev - if (!prev?.amountOut) return curr - return curr.amountOut.greaterThan(prev.amountOut) - ? curr - : prev - }, null) - - setSelectedQuote(best) - - if (best?.amountOut) { - const receiveDecimals = best.assetOut.decimals ?? 0 - setReceiveAmount( - baseUnitsToDisplayUnits( - best.amountOut, - receiveDecimals, - ), - ) - } else { - setReceiveAmount(null) - } + setAllQuotes(result) } catch { if (cancelled) return - setReceiveAmount(null) - setSelectedQuote(null) + setAllQuotes([]) } } @@ -223,6 +211,46 @@ export const useSwapForm = (): UseSwapFormResult => { } }, [debouncedPayAmount, slippage, selectedAccount, deviceId]) + const bestQuote = useMemo(() => pickBestByAmountOut(allQuotes), [allQuotes]) + + // When a manually selected provider drops from the latest quote set, fall + // back to the best quote for display and reset selection state to null so + // providerSelectionMode reports 'auto'. + const selectedQuote = useMemo(() => { + if (selectedProviderName === null) return bestQuote + const match = allQuotes.find( + quote => quote.provider === selectedProviderName, + ) + return match ?? bestQuote + }, [allQuotes, selectedProviderName, bestQuote]) + + const providerSelectionMode: 'auto' | 'manual' = useMemo(() => { + if (selectedProviderName === null) return 'auto' + const matchExists = allQuotes.some( + quote => quote.provider === selectedProviderName, + ) + return matchExists ? 'manual' : 'auto' + }, [allQuotes, selectedProviderName]) + + useEffect(() => { + if (selectedProviderName === null) return + const matchExists = allQuotes.some( + quote => quote.provider === selectedProviderName, + ) + if (!matchExists) setSelectedProviderName(null) + }, [allQuotes, selectedProviderName]) + + useEffect(() => { + if (!selectedQuote?.amountOut) { + setReceiveAmount(null) + return + } + const receiveDecimals = selectedQuote.assetOut.decimals ?? 0 + setReceiveAmount( + baseUnitsToDisplayUnits(selectedQuote.amountOut, receiveDecimals), + ) + }, [selectedQuote]) + const canSwap = useMemo( () => selectedQuote !== null && @@ -241,7 +269,8 @@ export const useSwapForm = (): UseSwapFormResult => { setToAsset(fromAsset) setPayAmount(receiveAmount) setReceiveAmount(payAmount) - setSelectedQuote(null) + setAllQuotes([]) + setSelectedProviderName(null) resetQuoteMutation() }, [ fromAsset, @@ -288,7 +317,8 @@ export const useSwapForm = (): UseSwapFormResult => { setFromAsset(asset.assetId) setPayAmount(null) setReceiveAmount(null) - setSelectedQuote(null) + setAllQuotes([]) + setSelectedProviderName(null) resetQuoteMutation() }, [setFromAsset, resetQuoteMutation], @@ -298,12 +328,17 @@ export const useSwapForm = (): UseSwapFormResult => { (asset: AssetWithAccountBalance) => { setToAsset(asset.assetId) setReceiveAmount(null) - setSelectedQuote(null) + setAllQuotes([]) + setSelectedProviderName(null) resetQuoteMutation() }, [setToAsset, resetQuoteMutation], ) + const handleProviderApply = useCallback((providerName: string | null) => { + setSelectedProviderName(providerName) + }, []) + const successCloseTimer = useRunAfterDelay() const handleCloseConfirm = useCallback(() => { @@ -329,7 +364,8 @@ export const useSwapForm = (): UseSwapFormResult => { ) setPayAmount(null) setReceiveAmount(null) - setSelectedQuote(null) + setAllQuotes([]) + setSelectedProviderName(null) resetQuoteMutation() confirmModal.close() }, SUCCESS_DISPLAY_MS) @@ -385,18 +421,23 @@ export const useSwapForm = (): UseSwapFormResult => { isQuoteFetching, isQuoteError, selectedQuote, + allQuotes, + selectedProviderName, + providerSelectionMode, canSwap, swapStatus: swapExecution.status, payAssetModal, receiveAssetModal, configModal, confirmModal, + providerModal, handlePayAmountChange, handleSwapDirection, handleMaxPress, handlePayAssetSelected, handleReceiveAssetSelected, handleConfigApply, + handleProviderApply, handleConfirmSwap, handleOpenConfirm, handleCloseConfirm, diff --git a/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/ProviderSelectionItem.tsx b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/ProviderSelectionItem.tsx new file mode 100644 index 000000000..f613afc26 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/ProviderSelectionItem.tsx @@ -0,0 +1,55 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { ReactNode } from 'react' +import { PWRadioButton, PWText, PWView } from '@components/core' +import { useStyles } from './styles' + +export type ProviderSelectionItemProps = { + left: ReactNode + label: string + right: ReactNode + isSelected: boolean + onPress: () => void + testID?: string +} + +export const ProviderSelectionItem = ({ + left, + label, + right, + isSelected, + onPress, + testID, +}: ProviderSelectionItemProps) => { + const styles = useStyles() + + return ( + + + {left} + + {label} + + + {right} + + ) +} diff --git a/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/SwapProviderBottomSheet.tsx b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/SwapProviderBottomSheet.tsx new file mode 100644 index 000000000..2ad125000 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/SwapProviderBottomSheet.tsx @@ -0,0 +1,149 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useTheme } from '@rneui/themed' +import { + PWBottomSheet, + PWIcon, + PWImage, + PWText, + PWToolbar, + PWTouchableOpacity, + PWView, +} from '@components/core' +import { useLanguage } from '@hooks/useLanguage' +import type { SwapQuote } from '@perawallet/wallet-core-swaps' +import { ProviderSelectionItem } from './ProviderSelectionItem' +import { useSwapProviderBottomSheet } from './useSwapProviderBottomSheet' +import { useStyles } from './styles' + +export type SwapProviderBottomSheetProps = { + isVisible: boolean + onClose: () => void + quotes: SwapQuote[] + selectedProviderName: string | null + onApply: (providerName: string | null) => void +} + +export const SwapProviderBottomSheet = ({ + isVisible, + onClose, + quotes, + selectedProviderName, + onApply, +}: SwapProviderBottomSheetProps) => { + const { t } = useLanguage() + const styles = useStyles() + const { theme } = useTheme() + + const { userSelection, rows, handleSelect, handleApply } = + useSwapProviderBottomSheet({ + isVisible, + quotes, + selectedProviderName, + onApply, + onClose, + }) + + return ( + + + } + center={ + + {t('swap.provider.change_title')} + + } + right={ + + + {t('swap.provider.apply')} + + + } + /> + + + } + label={t('swap.provider.auto_label')} + right={ + + {t('swap.provider.auto_description')} + + } + isSelected={userSelection === null} + onPress={() => handleSelect(null)} + testID='swap-provider-option-auto' + /> + {rows.map((row, index) => ( + + ) : ( + + ) + } + label={row.displayName} + right={ + + + {row.amountDisplay} + + {row.fiatDisplay && ( + + {row.fiatDisplay} + + )} + + } + isSelected={userSelection === row.quote.provider} + onPress={() => handleSelect(row.quote.provider ?? null)} + testID={`swap-provider-option-${row.quote.provider ?? 'unknown'}`} + /> + ))} + + + ) +} diff --git a/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/__tests__/SwapProviderBottomSheet.spec.tsx b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/__tests__/SwapProviderBottomSheet.spec.tsx new file mode 100644 index 000000000..7dea61ca8 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/__tests__/SwapProviderBottomSheet.spec.tsx @@ -0,0 +1,366 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import React from 'react' +import { render, screen, fireEvent } from '@test-utils/render' +import { describe, it, expect, vi } from 'vitest' +import { Decimal } from 'decimal.js' +import type { SwapQuote } from '@perawallet/wallet-core-swaps' +import { SwapProviderBottomSheet } from '../SwapProviderBottomSheet' + +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (key: string) => key }), +})) + +vi.mock('@perawallet/wallet-core-assets', () => ({ + formatAssetAmount: (amount: Decimal, asset: { unitName?: string }) => + `${amount.toString()} ${asset.unitName ?? ''}`.trim(), +})) + +vi.mock('@perawallet/wallet-core-currencies', () => ({ + useCurrency: () => ({ + preferredCurrency: 'USD', + usdToPreferred: (value: Decimal) => value, + }), +})) + +vi.mock('@perawallet/wallet-core-shared', () => ({ + formatCurrency: (value: Decimal, _precision: number, currency: string) => + `${currency} ${value.toString()}`, +})) + +vi.mock('@perawallet/wallet-core-swaps', () => ({ + useProvidersQuery: () => ({ + data: [ + { + name: 'vestige', + displayName: 'Vestige.fi', + iconUrl: 'https://example.com/vestige.png', + }, + { + name: 'tinyman', + displayName: 'Tinyman', + iconUrl: 'https://example.com/tinyman.png', + }, + ], + }), +})) + +vi.mock('@components/core', () => ({ + PWBottomSheet: ({ + children, + isVisible, + }: { + children: React.ReactNode + isVisible: boolean + }) => (isVisible ?
{children}
: null), + PWToolbar: ({ + left, + center, + right, + }: { + left: React.ReactNode + center: React.ReactNode + right: React.ReactNode + }) => ( +
+ {left} + {center} + {right} +
+ ), + PWIcon: ({ name, onPress }: { name: string; onPress?: () => void }) => ( + + ), + PWRadioButton: ({ + children, + onPress, + testID, + containerStyle, + isSelected, + }: { + children?: React.ReactNode + onPress?: () => void + testID?: string + containerStyle?: unknown + isSelected?: boolean + }) => ( + + ), + PWView: ({ + children, + style, + testID, + }: { + children?: React.ReactNode + style?: unknown + testID?: string + }) => ( +
+ {children} +
+ ), +})) + +const createQuote = (overrides: Partial = {}): SwapQuote => ({ + assetIn: { + assetId: 0, + unitName: 'ALGO', + decimals: 6, + verificationTier: 'verified', + }, + assetOut: { + assetId: 31566704, + unitName: 'USDC', + decimals: 6, + verificationTier: 'verified', + }, + amountOut: new Decimal('600.08'), + amountOutUsdValue: '600.08', + provider: 'vestige', + providerDisplayName: 'Vestige.fi', + ...overrides, +}) + +describe('SwapProviderBottomSheet', () => { + it('does not render when not visible', () => { + render( + , + ) + + expect(screen.queryByTestId('bottom-sheet')).toBeNull() + }) + + it('renders Auto row and excludes the best-price quote from the list', () => { + render( + , + ) + + expect(screen.getByTestId('swap-provider-option-auto')).toBeDefined() + expect(screen.getByTestId('swap-provider-option-tinyman')).toBeDefined() + expect(screen.queryByTestId('swap-provider-option-vestige')).toBeNull() + }) + + it('auto row is marked selected when selectedProviderName is null', () => { + render( + , + ) + + expect( + screen.getByTestId('swap-provider-option-auto-radio').children + .length, + ).toBeGreaterThan(0) + }) + + it('calls onApply with draft and onClose when Apply is pressed', () => { + const onApply = vi.fn() + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('swap-provider-option-tinyman')) + fireEvent.click(screen.getByTestId('swap-provider-apply')) + + expect(onApply).toHaveBeenCalledWith('tinyman') + expect(onClose).toHaveBeenCalled() + }) + + it('Apply with Auto selected calls onApply with null', () => { + const onApply = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('swap-provider-option-auto')) + fireEvent.click(screen.getByTestId('swap-provider-apply')) + + expect(onApply).toHaveBeenCalledWith(null) + }) + + it('renders quotes without a provider name and applies null when selected', () => { + const onApply = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('swap-provider-option-unknown')) + fireEvent.click(screen.getByTestId('swap-provider-apply')) + + expect(onApply).toHaveBeenCalledWith(null) + }) + + it('renders provider rows ordered by amountOut descending', () => { + render( + , + ) + + const providerButtons = screen + .getAllByRole('button') + .map(button => button.getAttribute('data-testid') ?? '') + .filter(id => id.startsWith('swap-provider-option-')) + .filter(id => !id.endsWith('-radio')) + .filter(id => id !== 'swap-provider-option-auto') + + expect(providerButtons).toEqual([ + 'swap-provider-option-tinyman', + 'swap-provider-option-folks', + ]) + }) + + it('calls onClose when the cross icon is pressed', () => { + const onClose = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByTestId('icon-cross')) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/index.ts b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/index.ts new file mode 100644 index 000000000..05c07b139 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/index.ts @@ -0,0 +1,14 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export { SwapProviderBottomSheet } from './SwapProviderBottomSheet' +export type { SwapProviderBottomSheetProps } from './SwapProviderBottomSheet' diff --git a/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/styles.ts b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/styles.ts new file mode 100644 index 000000000..ad5833d34 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/styles.ts @@ -0,0 +1,67 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { makeStyles } from '@rneui/themed' + +export const useStyles = makeStyles(theme => ({ + container: { + paddingBottom: theme.spacing.lg, + }, + applyText: { + color: theme.colors.linkPrimary, + }, + list: { + paddingHorizontal: theme.spacing.lg, + paddingVertical: theme.spacing.md, + gap: theme.spacing.lg, + }, + item: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing.md, + paddingVertical: theme.spacing.sm, + paddingHorizontal: 0, + }, + itemLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + flex: 1, + }, + logo: { + width: theme.spacing.xl, + height: theme.spacing.xl, + borderRadius: theme.borderRadius.full, + overflow: 'hidden', + }, + itemLabel: { + color: theme.colors.textMain, + }, + itemRight: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.md, + }, + rightTextColumn: { + alignItems: 'flex-end', + }, + amountText: { + color: theme.colors.textMain, + }, + fiatText: { + color: theme.colors.textGray, + }, + autoDescription: { + color: theme.colors.textGray, + }, +})) diff --git a/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/useSwapProviderBottomSheet.ts b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/useSwapProviderBottomSheet.ts new file mode 100644 index 000000000..a2573f702 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderBottomSheet/useSwapProviderBottomSheet.ts @@ -0,0 +1,118 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Decimal } from 'decimal.js' +import { formatAssetAmount } from '@perawallet/wallet-core-assets' +import { useCurrency } from '@perawallet/wallet-core-currencies' +import { formatCurrency } from '@perawallet/wallet-core-shared' +import { + useProvidersQuery, + type SwapQuote, +} from '@perawallet/wallet-core-swaps' +import { sortQuotesByAmountOutDesc } from '../../hooks/swapQuoteHelpers' + +type UseSwapProviderBottomSheetParams = { + isVisible: boolean + quotes: SwapQuote[] + selectedProviderName: string | null + onApply: (providerName: string | null) => void + onClose: () => void +} + +export type SwapProviderRow = { + quote: SwapQuote + iconUrl: string | undefined + displayName: string + amountDisplay: string + fiatDisplay: string | undefined +} + +type UseSwapProviderBottomSheetResult = { + userSelection: string | null + rows: SwapProviderRow[] + handleSelect: (providerName: string | null) => void + handleApply: () => void +} + +export const useSwapProviderBottomSheet = ({ + isVisible, + quotes, + selectedProviderName, + onApply, + onClose, +}: UseSwapProviderBottomSheetParams): UseSwapProviderBottomSheetResult => { + const { preferredCurrency, usdToPreferred } = useCurrency() + const { data: providers } = useProvidersQuery() + + const [userSelection, setUserSelection] = useState( + selectedProviderName, + ) + + useEffect(() => { + if (isVisible) setUserSelection(selectedProviderName) + }, [isVisible, selectedProviderName]) + + const handleSelect = useCallback((providerName: string | null) => { + setUserSelection(providerName) + }, []) + + const handleApply = useCallback(() => { + onApply(userSelection) + onClose() + }, [userSelection, onApply, onClose]) + + const sortedQuotes = useMemo( + () => sortQuotesByAmountOutDesc(quotes), + [quotes], + ) + + // Auto row already represents the top quote; drop it from the explicit list. + const alternativeQuotes = useMemo( + () => sortedQuotes.slice(1), + [sortedQuotes], + ) + + const rows = useMemo( + () => + alternativeQuotes.map(quote => { + const providerItem = providers?.find( + item => item.name === quote.provider, + ) + const displayName = + quote.providerDisplayName ?? + providerItem?.displayName ?? + quote.provider ?? + '-' + const amountDisplay = quote.amountOut + ? formatAssetAmount(quote.amountOut, quote.assetOut) + : '-' + const fiatDisplay = quote.amountOutUsdValue + ? formatCurrency( + usdToPreferred(new Decimal(quote.amountOutUsdValue)), + 2, + preferredCurrency, + ) + : undefined + return { + quote, + iconUrl: providerItem?.iconUrl, + displayName, + amountDisplay, + fiatDisplay, + } + }), + [alternativeQuotes, providers, usdToPreferred, preferredCurrency], + ) + + return { userSelection, rows, handleSelect, handleApply } +} diff --git a/apps/mobile/src/modules/swap/components/SwapProviderRow/SwapProviderRow.tsx b/apps/mobile/src/modules/swap/components/SwapProviderRow/SwapProviderRow.tsx new file mode 100644 index 000000000..3a3046e8a --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderRow/SwapProviderRow.tsx @@ -0,0 +1,75 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useMemo } from 'react' +import { PWIcon, PWText, PWTouchableOpacity, PWView } from '@components/core' +import { useLanguage } from '@hooks/useLanguage' +import type { SwapQuote } from '@perawallet/wallet-core-swaps' +import { SwapProviderDisplay } from '../SwapProviderDisplay' +import { formatSwapRate } from '../../hooks/swapQuoteHelpers' +import { useStyles } from './styles' + +export type SwapProviderRowProps = { + quote: SwapQuote + selectionMode: 'auto' | 'manual' + onPress: () => void +} + +export const SwapProviderRow = ({ + quote, + selectionMode, + onPress, +}: SwapProviderRowProps) => { + const { t } = useLanguage() + const styles = useStyles() + + const rateDisplay = useMemo(() => formatSwapRate(quote), [quote]) + + const title = + selectionMode === 'auto' + ? t('swap.provider.title_auto') + : t('swap.provider.title_manual') + + return ( + + + {title} + + + + + + {rateDisplay} + + + + + + ) +} diff --git a/apps/mobile/src/modules/swap/components/SwapProviderRow/__tests__/SwapProviderRow.spec.tsx b/apps/mobile/src/modules/swap/components/SwapProviderRow/__tests__/SwapProviderRow.spec.tsx new file mode 100644 index 000000000..473428c5c --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderRow/__tests__/SwapProviderRow.spec.tsx @@ -0,0 +1,160 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import React from 'react' +import { render, screen, fireEvent } from '@test-utils/render' +import { describe, it, expect, vi } from 'vitest' +import { Decimal } from 'decimal.js' +import type { SwapQuote } from '@perawallet/wallet-core-swaps' +import { SwapProviderRow } from '../SwapProviderRow' + +vi.mock('@hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (key: string) => key }), +})) + +vi.mock('@perawallet/wallet-core-shared', () => ({ + DEFAULT_PRECISION: 2, + formatNumber: (value: Decimal) => ({ + sign: '', + integer: value.toFixed(2).split('.')[0], + fraction: `.${value.toFixed(2).split('.')[1] ?? '00'}`, + }), +})) + +vi.mock('@perawallet/wallet-core-assets', () => ({ + ALGO_ASSET: { decimals: 6 }, +})) + +vi.mock('../../SwapProviderDisplay', () => ({ + SwapProviderDisplay: ({ + providerDisplayName, + }: { + providerDisplayName?: string + }) => {providerDisplayName}, +})) + +vi.mock('@components/core', () => ({ + PWView: ({ + children, + style, + }: { + children?: React.ReactNode + style?: unknown + }) =>
{children}
, + PWText: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + PWIcon: ({ name }: { name: string }) => ( + + ), + PWTouchableOpacity: ({ + children, + onPress, + testID, + }: { + children?: React.ReactNode + onPress?: () => void + testID?: string + }) => ( + + ), +})) + +const createQuote = (overrides: Partial = {}): SwapQuote => ({ + assetIn: { + assetId: 0, + unitName: 'ALGO', + decimals: 6, + verificationTier: 'verified', + }, + assetOut: { + assetId: 31566704, + unitName: 'USDC', + decimals: 6, + verificationTier: 'verified', + }, + price: new Decimal('0.33'), + provider: 'vestige', + providerDisplayName: 'Vestige.fi', + ...overrides, +}) + +describe('SwapProviderRow', () => { + it('renders the auto title when selectionMode is auto', () => { + render( + , + ) + + expect(screen.getByText('swap.provider.title_auto')).toBeDefined() + }) + + it('renders the manual title when selectionMode is manual', () => { + render( + , + ) + + expect(screen.getByText('swap.provider.title_manual')).toBeDefined() + }) + + it('renders the quote rate', () => { + render( + , + ) + + expect(screen.getByText(/1 ALGO/)).toBeDefined() + expect(screen.getByText(/USDC/)).toBeDefined() + }) + + it('renders a dash rate when quote price is missing', () => { + render( + , + ) + + expect(screen.getByText('-')).toBeDefined() + }) + + it('calls onPress when the row is tapped', () => { + const onPress = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByTestId('swap-provider-row')) + + expect(onPress).toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/src/modules/swap/components/SwapProviderRow/index.ts b/apps/mobile/src/modules/swap/components/SwapProviderRow/index.ts new file mode 100644 index 000000000..6e3a6f798 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderRow/index.ts @@ -0,0 +1,14 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +export { SwapProviderRow } from './SwapProviderRow' +export type { SwapProviderRowProps } from './SwapProviderRow' diff --git a/apps/mobile/src/modules/swap/components/SwapProviderRow/styles.ts b/apps/mobile/src/modules/swap/components/SwapProviderRow/styles.ts new file mode 100644 index 000000000..f18b7c706 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapProviderRow/styles.ts @@ -0,0 +1,37 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { makeStyles } from '@rneui/themed' + +export const useStyles = makeStyles(theme => ({ + container: { + paddingHorizontal: theme.spacing.lg, + gap: theme.spacing.sm, + }, + title: { + color: theme.colors.textGray, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + gap: theme.spacing.sm, + }, + right: { + flexDirection: 'row', + alignItems: 'center', + gap: theme.spacing.sm, + }, + rate: { + color: theme.colors.textMain, + }, +})) diff --git a/apps/mobile/src/modules/swap/components/SwapQuoteDetails/SwapQuoteDetails.tsx b/apps/mobile/src/modules/swap/components/SwapQuoteDetails/SwapQuoteDetails.tsx index 8eab84d89..19de2479b 100644 --- a/apps/mobile/src/modules/swap/components/SwapQuoteDetails/SwapQuoteDetails.tsx +++ b/apps/mobile/src/modules/swap/components/SwapQuoteDetails/SwapQuoteDetails.tsx @@ -10,13 +10,13 @@ limitations under the License */ -import { useMemo } from 'react' -import { Decimal } from 'decimal.js' import { PWSkeleton, PWText, PWView } from '@components/core' import { useLanguage } from '@hooks/useLanguage' -import { formatNumber } from '@perawallet/wallet-core-shared' import type { SwapQuote } from '@perawallet/wallet-core-swaps' -import { formatAssetAmount } from '@perawallet/wallet-core-assets' +import { + useSwapQuoteDetails, + type PriceImpactLevel, +} from './useSwapQuoteDetails' import { useStyles } from './styles' export type SwapQuoteDetailsProps = { @@ -24,46 +24,27 @@ export type SwapQuoteDetailsProps = { isLoading?: boolean } -const PRICE_IMPACT_LOW_THRESHOLD = new Decimal(1) -const PRICE_IMPACT_HIGH_THRESHOLD = new Decimal(5) - export const SwapQuoteDetails = ({ quote, isLoading, }: SwapQuoteDetailsProps) => { const { t } = useLanguage() const styles = useStyles() + const { + rateDisplay, + priceImpactDisplay, + priceImpactLevel, + slippageDisplay, + peraFeeDisplay, + providerDisplay, + } = useSwapQuoteDetails(quote) - const priceImpactStyle = useMemo(() => { - if (!quote.priceImpact) return styles.value - if (quote.priceImpact.lessThan(PRICE_IMPACT_LOW_THRESHOLD)) - return styles.priceImpactLow - if (quote.priceImpact.lessThan(PRICE_IMPACT_HIGH_THRESHOLD)) - return styles.priceImpactMedium - return styles.priceImpactHigh - }, [quote.priceImpact, styles]) - - const formattedPeraFee = useMemo(() => { - if (!quote.peraFeeAmount) return '-' - return formatAssetAmount(quote.peraFeeAmount, quote.assetIn) - }, [quote.peraFeeAmount, quote.assetIn]) - - const formattedExchangeFee = useMemo(() => { - if (!quote.exchangeFeeAmount) return '-' - return formatAssetAmount(quote.exchangeFeeAmount, quote.assetIn) - }, [quote.exchangeFeeAmount, quote.assetIn]) - - const rateDisplay = useMemo(() => { - if (!quote.price) return '-' - const outDecimals = quote.assetOut.decimals ?? 6 - const { sign, integer, fraction } = formatNumber( - quote.price, - outDecimals, - undefined, - 2, - ) - return `1 ${quote.assetIn.unitName ?? ''} ≈ ${sign}${integer}${fraction} ${quote.assetOut.unitName ?? ''}` - }, [quote.price, quote.assetIn, quote.assetOut]) + const priceImpactStyleMap: Record = { + none: styles.value, + low: styles.priceImpactLow, + medium: styles.priceImpactMedium, + high: styles.priceImpactHigh, + } return ( - {quote.priceImpact - ? `${quote.priceImpact.toDecimalPlaces(2).toString()}%` - : '-'} + {priceImpactDisplay} )} @@ -133,7 +112,7 @@ export const SwapQuoteDetails = ({ variant='body' style={styles.value} > - {quote.slippage ? `${quote.slippage.toString()}%` : '-'} + {slippageDisplay} )} @@ -155,29 +134,7 @@ export const SwapQuoteDetails = ({ variant='body' style={styles.value} > - {formattedPeraFee} - - )} - - - - - {t('swap.quote.exchange_fee')} - - {isLoading ? ( - - ) : ( - - {formattedExchangeFee} + {peraFeeDisplay} )} @@ -199,7 +156,7 @@ export const SwapQuoteDetails = ({ variant='body' style={styles.value} > - {quote.providerDisplayName ?? quote.provider ?? '-'} + {providerDisplay} )} diff --git a/apps/mobile/src/modules/swap/components/SwapQuoteDetails/__tests__/SwapQuoteDetails.spec.tsx b/apps/mobile/src/modules/swap/components/SwapQuoteDetails/__tests__/SwapQuoteDetails.spec.tsx index 557736f2f..b19452e5b 100644 --- a/apps/mobile/src/modules/swap/components/SwapQuoteDetails/__tests__/SwapQuoteDetails.spec.tsx +++ b/apps/mobile/src/modules/swap/components/SwapQuoteDetails/__tests__/SwapQuoteDetails.spec.tsx @@ -24,6 +24,7 @@ vi.mock('@hooks/useLanguage', () => ({ })) vi.mock('@perawallet/wallet-core-shared', () => ({ + DEFAULT_PRECISION: 2, formatNumber: ( value: Decimal, maxPrecision: number, @@ -41,10 +42,25 @@ vi.mock('@perawallet/wallet-core-shared', () => ({ })) vi.mock('@perawallet/wallet-core-assets', () => ({ + ALGO_ASSET: { decimals: 6 }, formatAssetAmount: (amount: Decimal, asset: { unitName?: string }) => `${amount.toString()} ${asset.unitName ?? ''}`.trim(), })) +vi.mock('@perawallet/wallet-core-remote-config', () => ({ + RemoteConfigKeys: { + swap_price_impact_low_threshold: 'swap_price_impact_low_threshold', + swap_price_impact_high_threshold: 'swap_price_impact_high_threshold', + }, + useRemoteConfig: () => ({ + getNumberValue: (key: string) => { + if (key === 'swap_price_impact_low_threshold') return 1 + if (key === 'swap_price_impact_high_threshold') return 5 + return 0 + }, + }), +})) + vi.mock('@components/core', () => ({ PWView: ({ children, @@ -83,7 +99,6 @@ const createQuote = (overrides: Partial = {}): SwapQuote => ({ priceImpact: new Decimal('0.5'), slippage: new Decimal('0.5'), peraFeeAmount: new Decimal('1000'), - exchangeFeeAmount: new Decimal('2000'), provider: 'tinyman', providerDisplayName: 'Tinyman', ...overrides, @@ -98,7 +113,6 @@ describe('SwapQuoteDetails', () => { expect(screen.getByText(/swap.quote.price_impact/)).toBeDefined() expect(screen.getByText(/swap.quote.slippage_tolerance/)).toBeDefined() expect(screen.getByText(/swap.quote.pera_fee/)).toBeDefined() - expect(screen.getByText(/swap.quote.exchange_fee/)).toBeDefined() expect(screen.getByText(/swap.quote.provider/)).toBeDefined() expect(screen.getByText('Tinyman')).toBeDefined() }) @@ -113,7 +127,7 @@ describe('SwapQuoteDetails', () => { ) const skeletons = screen.getAllByTestId('skeleton') - expect(skeletons.length).toBe(6) + expect(skeletons.length).toBe(5) }) it('displays dash for missing optional fields', () => { @@ -121,7 +135,6 @@ describe('SwapQuoteDetails', () => { priceImpact: undefined, slippage: undefined, peraFeeAmount: undefined, - exchangeFeeAmount: undefined, provider: undefined, providerDisplayName: undefined, }) diff --git a/apps/mobile/src/modules/swap/components/SwapQuoteDetails/useSwapQuoteDetails.ts b/apps/mobile/src/modules/swap/components/SwapQuoteDetails/useSwapQuoteDetails.ts new file mode 100644 index 000000000..ccd6c9c71 --- /dev/null +++ b/apps/mobile/src/modules/swap/components/SwapQuoteDetails/useSwapQuoteDetails.ts @@ -0,0 +1,82 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { useMemo } from 'react' +import { Decimal } from 'decimal.js' +import { formatAssetAmount } from '@perawallet/wallet-core-assets' +import { + RemoteConfigKeys, + useRemoteConfig, +} from '@perawallet/wallet-core-remote-config' +import type { SwapQuote } from '@perawallet/wallet-core-swaps' +import { formatSwapRate } from '../../hooks/swapQuoteHelpers' + +export type PriceImpactLevel = 'none' | 'low' | 'medium' | 'high' + +type UseSwapQuoteDetailsResult = { + rateDisplay: string + priceImpactDisplay: string + priceImpactLevel: PriceImpactLevel + slippageDisplay: string + peraFeeDisplay: string + providerDisplay: string +} + +const getPriceImpactLevel = ( + priceImpact: Decimal | null | undefined, + lowThreshold: Decimal, + highThreshold: Decimal, +): PriceImpactLevel => { + if (!priceImpact) return 'none' + if (priceImpact.lessThan(lowThreshold)) return 'low' + if (priceImpact.lessThan(highThreshold)) return 'medium' + return 'high' +} + +export const useSwapQuoteDetails = ( + quote: SwapQuote, +): UseSwapQuoteDetailsResult => { + const remoteConfigService = useRemoteConfig() + + const lowThreshold = new Decimal( + remoteConfigService.getNumberValue( + RemoteConfigKeys.swap_price_impact_low_threshold, + ), + ) + const highThreshold = new Decimal( + remoteConfigService.getNumberValue( + RemoteConfigKeys.swap_price_impact_high_threshold, + ), + ) + + return useMemo( + () => ({ + rateDisplay: formatSwapRate(quote), + priceImpactDisplay: quote.priceImpact + ? `${quote.priceImpact.toDecimalPlaces(2).toString()}%` + : '-', + priceImpactLevel: getPriceImpactLevel( + quote.priceImpact, + lowThreshold, + highThreshold, + ), + slippageDisplay: quote.slippage + ? `${quote.slippage.toString()}%` + : '-', + peraFeeDisplay: quote.peraFeeAmount + ? formatAssetAmount(quote.peraFeeAmount, quote.assetIn) + : '-', + providerDisplay: quote.providerDisplayName ?? quote.provider ?? '-', + }), + [quote, lowThreshold, highThreshold], + ) +} diff --git a/apps/mobile/src/modules/swap/hooks/swapQuoteHelpers.ts b/apps/mobile/src/modules/swap/hooks/swapQuoteHelpers.ts new file mode 100644 index 000000000..9c01bc883 --- /dev/null +++ b/apps/mobile/src/modules/swap/hooks/swapQuoteHelpers.ts @@ -0,0 +1,46 @@ +/* + Copyright 2022-2025 Pera Wallet, LDA + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License + */ + +import { ALGO_ASSET } from '@perawallet/wallet-core-assets' +import { DEFAULT_PRECISION, formatNumber } from '@perawallet/wallet-core-shared' +import type { SwapQuote } from '@perawallet/wallet-core-swaps' + +export const pickBestByAmountOut = (quotes: SwapQuote[]): SwapQuote | null => + quotes.reduce((prev, curr) => { + if (!curr.amountOut) return prev + if (!prev?.amountOut) return curr + return curr.amountOut.greaterThan(prev.amountOut) ? curr : prev + }, null) + +export const sortQuotesByAmountOutDesc = (quotes: SwapQuote[]): SwapQuote[] => + [...quotes].sort((a, b) => { + if (!a.amountOut && !b.amountOut) return 0 + if (!a.amountOut) return 1 + if (!b.amountOut) return -1 + if (a.amountOut.greaterThan(b.amountOut)) return -1 + if (b.amountOut.greaterThan(a.amountOut)) return 1 + return 0 + }) + +export const formatSwapRate = (quote: SwapQuote): string => { + if (!quote.price) return '-' + const outDecimals = quote.assetOut.decimals ?? ALGO_ASSET.decimals + const { sign, integer, fraction } = formatNumber( + quote.price, + outDecimals, + undefined, + DEFAULT_PRECISION, + ) + const inUnit = quote.assetIn.unitName ?? '' + const outUnit = quote.assetOut.unitName ?? '' + return `1 ${inUnit} ≈ ${sign}${integer}${fraction} ${outUnit}` +} diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 8b0dbd202..505db729d 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -294,6 +294,8 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + pool: 'forks', + fileParallelism: false, setupFiles: ['./vitest.setup.ts'], server: { deps: { diff --git a/extensions/platform/src/remote-config/models/index.ts b/extensions/platform/src/remote-config/models/index.ts index 247f5d689..9d301d940 100644 --- a/extensions/platform/src/remote-config/models/index.ts +++ b/extensions/platform/src/remote-config/models/index.ts @@ -15,6 +15,8 @@ export const RemoteConfigKeys = { fee_warning_standard_fee: 'fee_warning_standard_fee', fee_warning_usd_threshold: 'fee_warning_usd_threshold', staking_projects: 'staking_projects', + swap_price_impact_low_threshold: 'swap_price_impact_low_threshold', + swap_price_impact_high_threshold: 'swap_price_impact_high_threshold', } as const export type RemoteConfigKey = @@ -28,6 +30,8 @@ export const RemoteConfigDefaults: Record< fee_warning_standard_fee: 0.001, fee_warning_usd_threshold: 0.01, staking_projects: '', + swap_price_impact_low_threshold: 1, + swap_price_impact_high_threshold: 5, } export interface RemoteConfigService { diff --git a/packages/swaps/src/api/quotes/endpoints.ts b/packages/swaps/src/api/quotes/endpoints.ts index d42a10b65..edb678a9c 100644 --- a/packages/swaps/src/api/quotes/endpoints.ts +++ b/packages/swaps/src/api/quotes/endpoints.ts @@ -123,7 +123,6 @@ export const createQuotes = async ( price: toOptionalDecimal(quote.price), priceImpact: toOptionalDecimal(quote.price_impact), peraFeeAmount: toOptionalDecimal(quote.pera_fee_amount), - exchangeFeeAmount: toOptionalDecimal(quote.exchange_fee_amount), transactionFees: toNullableDecimal(quote.transaction_fees), })) } diff --git a/packages/swaps/src/api/quotes/schema.ts b/packages/swaps/src/api/quotes/schema.ts index 1b3b5b293..0a74e31d8 100644 --- a/packages/swaps/src/api/quotes/schema.ts +++ b/packages/swaps/src/api/quotes/schema.ts @@ -71,7 +71,6 @@ export const quoteSchema = z.object({ price: z.string().optional(), price_impact: z.string().optional(), pera_fee_amount: z.string().optional(), - exchange_fee_amount: z.string().optional(), transaction_fees: z.string().nullable().optional(), }) diff --git a/packages/swaps/src/models/index.ts b/packages/swaps/src/models/index.ts index 6495926df..3bd05b2b6 100644 --- a/packages/swaps/src/models/index.ts +++ b/packages/swaps/src/models/index.ts @@ -109,7 +109,6 @@ export interface SwapQuote { price?: Decimal priceImpact?: Decimal peraFeeAmount?: Decimal - exchangeFeeAmount?: Decimal transactionFees?: Decimal | null }