diff --git a/locales/base/translation.json b/locales/base/translation.json
index 51afedb3ad8..919e22375e4 100644
--- a/locales/base/translation.json
+++ b/locales/base/translation.json
@@ -2825,6 +2825,8 @@
"tokenEnterAmount": {
"availableBalance": "Available: <0>0>",
"selectToken": "Select token",
- "fiatPriceUnavailable": "Price unavailable"
- }
+ "fiatPriceUnavailable": "Price unavailable",
+ "tokenDescription": "{{tokenName}} on {{tokenNetwork}}"
+ },
+ "on": "on"
}
diff --git a/src/components/TokenEnterAmount.test.tsx b/src/components/TokenEnterAmount.test.tsx
index 5c29df61428..80abd3c5c63 100644
--- a/src/components/TokenEnterAmount.test.tsx
+++ b/src/components/TokenEnterAmount.test.tsx
@@ -280,7 +280,9 @@ describe('TokenEnterAmount', () => {
)
- expect(getByTestId('TokenEnterAmount/TokenName')).toHaveTextContent('CELO on Celo Alfajores')
+ expect(getByTestId('TokenEnterAmount/TokenName')).toHaveTextContent(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"CELO","tokenNetwork":"Celo Alfajores"}'
+ )
expect(getByTestId('TokenEnterAmount/SwitchTokens')).toBeTruthy()
expect(getByTestId('TokenEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('TokenEnterAmount/TokenBalance')).toHaveTextContent(
@@ -403,17 +405,17 @@ describe('TokenEnterAmount', () => {
)
const input = getByTestId('TokenEnterAmount/TokenAmountInput')
- expect(input.props.editable).toBe(false)
+ expect(input).toBeDisabled()
})
it('shows unavailable fiat price message when priceUsd is undefined', () => {
diff --git a/src/components/TokenEnterAmount.tsx b/src/components/TokenEnterAmount.tsx
index 07301b28f4b..cd40ee778cb 100644
--- a/src/components/TokenEnterAmount.tsx
+++ b/src/components/TokenEnterAmount.tsx
@@ -11,6 +11,7 @@ import {
View,
} from 'react-native'
import { getNumberFormatSettings } from 'react-native-localize'
+import SkeletonPlaceholder from 'react-native-skeleton-placeholder'
import TextInput from 'src/components/TextInput'
import TokenDisplay from 'src/components/TokenDisplay'
import TokenIcon, { IconSize } from 'src/components/TokenIcon'
@@ -105,12 +106,10 @@ export function getDisplayLocalAmount(
* variables and handlers that manage "enter amount" functionality, including rate calculations.
*/
export function useEnterAmount(props: {
- token: TokenBalance
+ token: TokenBalance | undefined
inputRef: React.RefObject
onHandleAmountInputChange?(amount: string): void
}) {
- const { decimalSeparator } = getNumberFormatSettings()
-
/**
* This field is formatted for processing purpose. It is a lot easier to process a number formatted
* in a single format, rather than writing different logic for various combinations of decimal
@@ -139,6 +138,15 @@ export function useEnterAmount(props: {
* - `local.displayAmount` -> `localDisplayAmount`
*/
const processedAmounts = useMemo(() => {
+ const { decimalSeparator } = getNumberFormatSettings()
+
+ if (!props.token) {
+ return {
+ token: { bignum: null, displayAmount: '' },
+ local: { bignum: null, displayAmount: '' },
+ }
+ }
+
if (amountType === 'token') {
const parsedTokenAmount = amount === '' ? null : parseInputAmount(amount)
@@ -201,9 +209,11 @@ export function useEnterAmount(props: {
displayAmount: getDisplayLocalAmount(parsedLocalAmount, localCurrencySymbol),
},
}
- }, [amount, amountType, localCurrencySymbol])
+ }, [amount, amountType, localCurrencySymbol, usdToLocalRate, props.token])
function handleToggleAmountType() {
+ if (!props.token) return
+
const newAmountType = amountType === 'local' ? 'token' : 'local'
setAmountType(newAmountType)
setAmount(
@@ -218,7 +228,7 @@ export function useEnterAmount(props: {
value = unformatNumberForProcessing(value)
value = value.startsWith('.') ? `0${value}` : value
- if (!value) {
+ if (!value || !props.token) {
setAmount('')
props.onHandleAmountInputChange?.('')
return
@@ -231,17 +241,30 @@ export function useEnterAmount(props: {
`^(?:\\d+[.]?\\d{0,${props.token.decimals}}|[.]\\d{0,${props.token.decimals}}|[.])$`
)
- if (
- (amountType === 'token' && value.match(tokenAmountRegex)) ||
- (amountType === 'local' && value.match(localAmountRegex))
- ) {
+ const isValidTokenAmount = amountType === 'token' && value.match(tokenAmountRegex)
+ const isValidLocalAmount = amountType === 'local' && value.match(localAmountRegex)
+ if (isValidTokenAmount || isValidLocalAmount) {
setAmount(value)
props.onHandleAmountInputChange?.(value)
return
}
}
+ function replaceAmount(value: string) {
+ if (!props.token) return
+
+ if (value === '') {
+ setAmount('')
+ return
+ }
+
+ const rawValue = unformatNumberForProcessing(value)
+ const roundedAmount = new BigNumber(rawValue).decimalPlaces(props.token?.decimals).toString()
+ setAmount(roundedAmount)
+ }
+
function handleSelectPercentageAmount(percentage: number) {
+ if (!props.token) return
if (percentage <= 0 || percentage > 1) return
const percentageAmount = props.token.balance.multipliedBy(percentage)
@@ -265,7 +288,7 @@ export function useEnterAmount(props: {
amount,
amountType,
processedAmounts,
- replaceAmount: setAmount,
+ replaceAmount,
handleToggleAmountType,
handleAmountInputChange,
handleSelectPercentageAmount,
@@ -281,11 +304,11 @@ export default function TokenEnterAmount({
inputRef,
inputStyle,
autoFocus,
- editable = true,
testID,
onInputChange,
toggleAmountType,
onOpenTokenPicker,
+ loading,
}: {
token?: TokenBalance
inputValue: string
@@ -293,18 +316,19 @@ export default function TokenEnterAmount({
localAmount: string
amountType: AmountEnteredIn
inputRef: React.MutableRefObject
+ loading?: boolean
inputStyle?: StyleProp
autoFocus?: boolean
- editable?: boolean
testID?: string
- onInputChange(value: string): void
+ onInputChange?(value: string): void
toggleAmountType?(): void
onOpenTokenPicker?(): void
}) {
const { t } = useTranslation()
- // the startPosition and inputRef variables exist to ensure TextInput
- // displays the start of the value for long values on Android
- // https://github.com/facebook/react-native/issues/14845
+ /**
+ * startPosition and inputRef variables exist to ensure TextInput displays the start of the value
+ * for long values on Android: https://github.com/facebook/react-native/issues/14845
+ */
const [startPosition, setStartPosition] = useState(0)
// this should never be null, just adding a default to make TS happy
const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD
@@ -359,7 +383,10 @@ export default function TokenEnterAmount({
- {token.symbol} on {NETWORK_NAMES[token.networkId]}
+ {t('tokenEnterAmount.tokenDescription', {
+ tokenName: token.symbol,
+ tokenNetwork: NETWORK_NAMES[token.networkId],
+ })}
@@ -381,81 +408,95 @@ export default function TokenEnterAmount({
{token && (
-
- {
- handleSetStartPosition(undefined)
- onInputChange(value)
- }}
- value={formattedInputValue}
- placeholderTextColor={Colors.gray3}
- placeholder={amountType === 'token' ? placeholder.token : placeholder.local}
- keyboardType="decimal-pad"
- // Work around for RN issue with Samsung keyboards
- // https://github.com/facebook/react-native/issues/22005
- autoCapitalize="words"
- autoFocus={autoFocus}
- // unset lineHeight to allow ellipsis on long inputs on iOS. For
- // android, ellipses doesn't work and unsetting line height causes
- // height changes when amount is entered
- inputStyle={[
- styles.primaryAmountText,
- inputStyle,
- Platform.select({ ios: { lineHeight: undefined } }),
+
+ {
- handleSetStartPosition(0)
- }}
- onFocus={() => {
- const withCurrency = amountType === 'local' ? 1 : 0
- handleSetStartPosition((inputValue?.length ?? 0) + withCurrency)
- }}
- onSelectionChange={() => {
- handleSetStartPosition(undefined)
- }}
- selection={
- Platform.OS === 'android' && typeof startPosition === 'number'
- ? { start: startPosition }
- : undefined
- }
- showClearButton={false}
- editable={editable}
- testID={`${testID}/TokenAmountInput`}
- />
-
- {token.priceUsd ? (
- <>
- {toggleAmountType && (
-
+ {
+ handleSetStartPosition(undefined)
+ onInputChange?.(value)
+ }}
+ value={formattedInputValue}
+ placeholderTextColor={Colors.gray3}
+ placeholder={amountType === 'token' ? placeholder.token : placeholder.local}
+ keyboardType="decimal-pad"
+ // Work around for RN issue with Samsung keyboards
+ // https://github.com/facebook/react-native/issues/22005
+ autoCapitalize="words"
+ autoFocus={autoFocus}
+ // unset lineHeight to allow ellipsis on long inputs on iOS. For
+ // android, ellipses doesn't work and unsetting line height causes
+ // height changes when amount is entered
+ inputStyle={[
+ styles.primaryAmountText,
+ inputStyle,
+ Platform.select({ ios: { lineHeight: undefined } }),
+ ]}
+ onBlur={() => {
+ handleSetStartPosition(0)
+ }}
+ onFocus={() => {
+ const withCurrency = amountType === 'local' ? 1 : 0
+ handleSetStartPosition((inputValue?.length ?? 0) + withCurrency)
+ }}
+ onSelectionChange={() => {
+ handleSetStartPosition(undefined)
+ }}
+ selection={
+ Platform.OS === 'android' && typeof startPosition === 'number'
+ ? { start: startPosition }
+ : undefined
+ }
+ showClearButton={false}
+ editable={!!onInputChange}
+ testID={`${testID}/TokenAmountInput`}
+ />
+
+ {token.priceUsd ? (
+ <>
+ {toggleAmountType && (
+
+
+
+ )}
+
+
-
-
- )}
-
-
- {amountType === 'token'
- ? `${APPROX_SYMBOL} ${localAmount ? localAmount : placeholder.local}`
- : `${APPROX_SYMBOL} ${tokenAmount ? tokenAmount : placeholder.token}`}
+ {amountType === 'token'
+ ? `${APPROX_SYMBOL} ${localAmount ? localAmount : placeholder.local}`
+ : `${APPROX_SYMBOL} ${tokenAmount ? tokenAmount : placeholder.token}`}
+
+ >
+ ) : (
+
+ {t('tokenEnterAmount.fiatPriceUnavailable')}
- >
- ) : (
-
- {t('tokenEnterAmount.fiatPriceUnavailable')}
-
+ )}
+
+
+ {loading && (
+
+
+
+
+
)}
)}
@@ -511,4 +552,12 @@ const styles = StyleSheet.create({
swapArrowContainer: {
transform: [{ rotate: '90deg' }],
},
+ loader: {
+ padding: Spacing.Regular16,
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ height: '100%',
+ width: '100%',
+ },
})
diff --git a/src/navigator/Navigator.tsx b/src/navigator/Navigator.tsx
index f105daa998a..ac36f1fe08e 100644
--- a/src/navigator/Navigator.tsx
+++ b/src/navigator/Navigator.tsx
@@ -112,8 +112,11 @@ import ValidateRecipientAccount, {
import ValidateRecipientIntro, {
validateRecipientIntroScreenNavOptions,
} from 'src/send/ValidateRecipientIntro'
+import { getFeatureGate } from 'src/statsig'
+import { StatsigFeatureGates } from 'src/statsig/types'
import variables from 'src/styles/variables'
import SwapScreen from 'src/swap/SwapScreen'
+import SwapScreenV2 from 'src/swap/SwapScreenV2'
import TokenDetailsScreen from 'src/tokens/TokenDetails'
import TokenImportScreen from 'src/tokens/TokenImport'
import TransactionDetailsScreen from 'src/transactions/feed/TransactionDetailsScreen'
@@ -557,11 +560,21 @@ const earnScreens = (Navigator: typeof Stack) => (
>
)
-const swapScreens = (Navigator: typeof Stack) => (
- <>
-
- >
-)
+const swapScreens = (Navigator: typeof Stack) => {
+ const showNewEnterAmountForSwap = getFeatureGate(
+ StatsigFeatureGates.SHOW_NEW_ENTER_AMOUNT_FOR_SWAP
+ )
+
+ return (
+ <>
+
+ >
+ )
+}
const nftScreens = (Navigator: typeof Stack) => (
<>
diff --git a/src/send/EnterAmount.test.tsx b/src/send/EnterAmount.test.tsx
index f2ea9fd9641..63b5a8c7d31 100644
--- a/src/send/EnterAmount.test.tsx
+++ b/src/send/EnterAmount.test.tsx
@@ -185,7 +185,11 @@ describe('EnterAmount', () => {
expect(getByTestId('SendEnterAmount/TokenAmountInput')).toBeTruthy()
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH')
- expect(getByText('ETH on Ethereum Sepolia')).toBeTruthy()
+ expect(
+ getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"ETH","tokenNetwork":"Ethereum Sepolia"}'
+ )
+ ).toBeTruthy()
expect(getByTestId('SendEnterAmount/ReviewButton')).toBeDisabled()
})
@@ -407,12 +411,20 @@ describe('EnterAmount', () => {
)
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('POOF')
- expect(getByText('POOF on Celo Alfajores')).toBeTruthy()
+ expect(
+ getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"POOF","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
fireEvent.press(getByTestId('SendEnterAmount/TokenSelect'))
await waitFor(() => expect(getByText('Ether')).toBeTruthy())
fireEvent.press(getByText('Ether'))
expect(getByTestId('SendEnterAmount/TokenSelect')).toHaveTextContent('ETH')
- expect(getByText('ETH on Ethereum Sepolia')).toBeTruthy()
+ expect(
+ getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"ETH","tokenNetwork":"Ethereum Sepolia"}'
+ )
+ ).toBeTruthy()
expect(AppAnalytics.track).toHaveBeenCalledTimes(2)
expect(AppAnalytics.track).toHaveBeenCalledWith(SendEvents.token_dropdown_opened, {
currentNetworkId: NetworkId['celo-alfajores'],
@@ -846,7 +858,11 @@ describe('EnterAmount', () => {
)
expect(queryByTestId('SendEnterAmount/Fee')).toBeFalsy()
- expect(getByText('CELO on Celo Alfajores')).toBeTruthy()
+ expect(
+ getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"CELO","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '8')
fireEvent.changeText(getByTestId('SendEnterAmount/TokenAmountInput'), '9')
diff --git a/src/statsig/types.ts b/src/statsig/types.ts
index 0fe7af1a661..af654b58e9e 100644
--- a/src/statsig/types.ts
+++ b/src/statsig/types.ts
@@ -34,6 +34,7 @@ export enum StatsigFeatureGates {
SHOW_UK_COMPLIANT_VARIANT = 'show_uk_compliant_variant',
ALLOW_EARN_PARTIAL_WITHDRAWAL = 'allow_earn_partial_withdrawal',
SHOW_ZERION_TRANSACTION_FEED = 'show_zerion_transaction_feed',
+ SHOW_NEW_ENTER_AMOUNT_FOR_SWAP = 'show_new_enter_amount_for_swap',
ALLOW_CROSS_CHAIN_SWAP_AND_DEPOSIT = 'allow_cross_chain_swap_and_deposit',
}
diff --git a/src/swap/SwapScreenV2.test.tsx b/src/swap/SwapScreenV2.test.tsx
new file mode 100644
index 00000000000..82c02b248a4
--- /dev/null
+++ b/src/swap/SwapScreenV2.test.tsx
@@ -0,0 +1,2016 @@
+import { act, fireEvent, render, waitFor, within } from '@testing-library/react-native'
+import BigNumber from 'bignumber.js'
+import { FetchMock } from 'jest-fetch-mock/types'
+import React from 'react'
+import { DeviceEventEmitter } from 'react-native'
+import { Provider } from 'react-redux'
+import { ReactTestInstance } from 'react-test-renderer'
+import { showError } from 'src/alert/actions'
+import AppAnalytics from 'src/analytics/AppAnalytics'
+import { SwapEvents } from 'src/analytics/Events'
+import { ErrorMessages } from 'src/app/ErrorMessages'
+import { APPROX_SYMBOL } from 'src/components/TokenEnterAmount'
+import { navigate } from 'src/navigator/NavigationService'
+import { Screens } from 'src/navigator/Screens'
+import { NETWORK_NAMES } from 'src/shared/conts'
+import {
+ getDynamicConfigParams,
+ getExperimentParams,
+ getFeatureGate,
+ getMultichainFeatures,
+} from 'src/statsig'
+import { StatsigFeatureGates } from 'src/statsig/types'
+import SwapScreenV2 from 'src/swap/SwapScreenV2'
+import { swapStart } from 'src/swap/slice'
+import { FetchQuoteResponse, Field } from 'src/swap/types'
+import { NO_QUOTE_ERROR_MESSAGE } from 'src/swap/useSwapQuote'
+import { NetworkId } from 'src/transactions/types'
+import { publicClient } from 'src/viem'
+import { SerializableTransactionRequest } from 'src/viem/preparedTransactionSerialization'
+import networkConfig from 'src/web3/networkConfig'
+import MockedNavigator from 'test/MockedNavigator'
+import { createMockStore } from 'test/utils'
+import {
+ mockAccount,
+ mockCeloAddress,
+ mockCeloTokenId,
+ mockCeurTokenId,
+ mockCusdAddress,
+ mockCusdTokenId,
+ mockEthTokenId,
+ mockPoofTokenId,
+ mockTestTokenTokenId,
+ mockTokenBalances,
+ mockUSDCTokenId,
+} from 'test/values'
+
+const mockFetch = fetch as FetchMock
+const mockGetNumberFormatSettings = jest.fn()
+
+// Use comma as decimal separator for all tests here
+// Input with "." will still work, but it will also work with ",".
+jest.mock('react-native-localize', () => ({
+ getNumberFormatSettings: () => mockGetNumberFormatSettings(),
+}))
+
+jest.mock('src/web3/networkConfig', () => {
+ const originalModule = jest.requireActual('src/web3/networkConfig')
+ return {
+ ...originalModule,
+ __esModule: true,
+ default: {
+ ...originalModule.default,
+ defaultNetworkId: 'celo-alfajores',
+ },
+ }
+})
+
+jest.mock('src/statsig')
+
+jest.mock('viem/actions', () => ({
+ ...jest.requireActual('viem/actions'),
+ estimateGas: jest.fn(async () => BigInt(21_000)),
+}))
+
+jest.mock('src/viem/estimateFeesPerGas', () => ({
+ estimateFeesPerGas: jest.fn(async () => ({
+ maxFeePerGas: BigInt(12_000_000_000),
+ maxPriorityFeePerGas: BigInt(2_000_000_000),
+ baseFeePerGas: BigInt(6_000_000_000),
+ })),
+}))
+
+const mockStoreTokenBalances = {
+ [mockCeurTokenId]: {
+ ...mockTokenBalances[mockCeurTokenId],
+ isSwappable: true,
+ balance: '0',
+ priceUsd: '5.03655958698530226301',
+ },
+ [mockCusdTokenId]: {
+ ...mockTokenBalances[mockCusdTokenId],
+ isSwappable: true,
+ priceUsd: '1',
+ },
+ [mockCeloTokenId]: {
+ ...mockTokenBalances[mockCeloTokenId],
+ isSwappable: true,
+ priceUsd: '13.05584965485329753569',
+ },
+ [mockTestTokenTokenId]: {
+ tokenId: mockTestTokenTokenId,
+ networkId: NetworkId['celo-alfajores'],
+ symbol: 'TT',
+ name: 'Test Token',
+ isSwappable: false,
+ balance: '100',
+ // no priceUsd
+ priceUsd: undefined,
+ },
+ [mockPoofTokenId]: {
+ ...mockTokenBalances[mockPoofTokenId],
+ isSwappable: true,
+ balance: '100',
+ // no priceUsd
+ priceUsd: undefined,
+ },
+ [mockEthTokenId]: {
+ ...mockTokenBalances[mockEthTokenId],
+ isSwappable: true,
+ priceUsd: '2000',
+ balance: '10',
+ },
+ [mockUSDCTokenId]: {
+ ...mockTokenBalances[mockUSDCTokenId],
+ isSwappable: true,
+ balance: '10',
+ priceUsd: '1',
+ imageUrl: 'https://example.com/usdc.png',
+ },
+}
+
+const renderScreen = ({
+ celoBalance = '10',
+ cUSDBalance = '20.456',
+ fromTokenId = undefined,
+ isPoofSwappable = true,
+ poofBalance = '100',
+ lastSwapped = [],
+ toTokenNetworkId = undefined,
+}: {
+ celoBalance?: string
+ cUSDBalance?: string
+ fromTokenId?: string
+ isPoofSwappable?: boolean
+ poofBalance?: string
+ lastSwapped?: string[]
+ toTokenNetworkId?: NetworkId
+}) => {
+ const store = createMockStore({
+ tokens: {
+ tokenBalances: {
+ ...mockStoreTokenBalances,
+ [mockCusdTokenId]: {
+ ...mockStoreTokenBalances[mockCusdTokenId],
+ balance: cUSDBalance,
+ },
+ [mockCeloTokenId]: {
+ ...mockStoreTokenBalances[mockCeloTokenId],
+ balance: celoBalance,
+ },
+ [mockPoofTokenId]: {
+ ...mockStoreTokenBalances[mockPoofTokenId],
+ isSwappable: isPoofSwappable,
+ balance: poofBalance,
+ },
+ },
+ },
+ swap: {
+ lastSwapped,
+ },
+ })
+
+ const tree = render(
+
+
+
+ )
+ const [swapFromContainer, swapToContainer] = tree.getAllByTestId('SwapAmountInput')
+ const tokenBottomSheets = tree.getAllByTestId('TokenBottomSheet')
+ const swapScreen = tree.getByTestId('SwapScreen')
+
+ return {
+ ...tree,
+ store,
+ swapFromContainer,
+ swapToContainer,
+ tokenBottomSheets,
+ swapScreen,
+ }
+}
+
+const defaultQuote: FetchQuoteResponse = {
+ unvalidatedSwapTransaction: {
+ swapType: 'same-chain',
+ chainId: 44787,
+ price: '1.2345678',
+ guaranteedPrice: '1.1234567',
+ appFeePercentageIncludedInPrice: undefined,
+ sellTokenAddress: mockCeloAddress,
+ buyTokenAddress: mockCusdAddress,
+ sellAmount: '1234000000000000000',
+ buyAmount: '1523456665200000000',
+ allowanceTarget: '0x0000000000000000000000000000000000000123',
+ from: mockAccount,
+ to: '0x0000000000000000000000000000000000000123',
+ value: '0',
+ data: '0x0',
+ gas: '1800000',
+ estimatedGasUse: undefined,
+ estimatedPriceImpact: '0.1',
+ },
+ details: {
+ swapProvider: 'someProvider',
+ },
+}
+const defaultQuoteResponse = JSON.stringify(defaultQuote)
+
+const preparedTransactions: SerializableTransactionRequest[] = [
+ {
+ data: '0x095ea7b3000000000000000000000000000000000000000000000000000000000000012300000000000000000000000000000000000000000000000011200c7644d50000',
+ from: '0x0000000000000000000000000000000000007E57',
+ gas: '21000',
+ maxFeePerGas: '12000000000',
+ maxPriorityFeePerGas: '2000000000',
+ _baseFeePerGas: '6000000000',
+ to: '0xf194afdf50b03e69bd7d057c1aa9e10c9954e4c9',
+ },
+ {
+ data: '0x0',
+ from: '0x0000000000000000000000000000000000007E57',
+ gas: '1800000',
+ maxFeePerGas: '12000000000',
+ maxPriorityFeePerGas: '2000000000',
+ _baseFeePerGas: '6000000000',
+ to: '0x0000000000000000000000000000000000000123',
+ value: '0',
+ },
+]
+
+const mockTxFeesLearnMoreUrl = 'https://example.com/tx-fees-learn-more'
+
+const selectSingleSwapToken = (
+ swapAmountContainer: ReactTestInstance,
+ tokenSymbol: string,
+ swapScreen: ReactTestInstance,
+ swapFieldType: Field
+) => {
+ const token = Object.values(mockStoreTokenBalances).find((token) => token.symbol === tokenSymbol)
+ expect(token).toBeTruthy()
+
+ const [fromTokenBottomSheet, toTokenBottomSheet] =
+ within(swapScreen).getAllByTestId('TokenBottomSheet')
+ const tokenBottomSheet = swapFieldType === Field.FROM ? fromTokenBottomSheet : toTokenBottomSheet
+
+ fireEvent.press(within(swapAmountContainer).getByTestId('SwapAmountInput/TokenSelect'))
+ fireEvent.press(within(tokenBottomSheet).getByText(token!.name))
+
+ if (swapFieldType === Field.TO && !token!.priceUsd) {
+ fireEvent.press(within(swapScreen).getByText('swapScreen.noUsdPriceWarning.ctaConfirm'))
+ }
+
+ expect(
+ within(swapAmountContainer).getByText(
+ `tokenEnterAmount.tokenDescription, {"tokenName":"${token!.symbol}","tokenNetwork":"${NETWORK_NAMES[token!.networkId]}"}`
+ )
+ ).toBeTruthy()
+
+ if (swapFieldType === Field.TO && !token!.priceUsd) {
+ expect(
+ within(swapScreen).getByText(
+ `swapScreen.noUsdPriceWarning.description, {"localCurrency":"PHP","tokenSymbol":"${tokenSymbol}"}`
+ )
+ ).toBeTruthy()
+ }
+}
+
+const selectSwapTokens = (
+ fromTokenSymbol: string,
+ toTokenSymbol: string,
+ swapScreen: ReactTestInstance
+) => {
+ const tokenSymbols = [fromTokenSymbol, toTokenSymbol]
+ const swapInputContainers = within(swapScreen).getAllByTestId('SwapAmountInput')
+
+ for (let i = 0; i < 2; i++) {
+ const tokenSymbol = tokenSymbols[i]
+ const swapInputContainer = swapInputContainers[i]
+
+ selectSingleSwapToken(
+ swapInputContainer,
+ tokenSymbol,
+ swapScreen,
+ i === 0 ? Field.FROM : Field.TO
+ )
+ }
+}
+
+const selectMaxFromAmount = async (swapScreen: ReactTestInstance) => {
+ await act(() => {
+ DeviceEventEmitter.emit('keyboardDidShow', { endCoordinates: { height: 100 } })
+ })
+
+ const amountPercentageComponent = within(swapScreen).getByTestId('SwapEnterAmount/AmountOptions')
+ fireEvent.press(within(amountPercentageComponent).getByText('maxSymbol'))
+}
+
+describe('SwapScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockFetch.resetMocks()
+
+ mockGetNumberFormatSettings.mockReturnValue({ decimalSeparator: '.' })
+ BigNumber.config({
+ FORMAT: {
+ decimalSeparator: '.',
+ },
+ })
+
+ jest.mocked(getFeatureGate).mockReset()
+ jest.mocked(getExperimentParams).mockReturnValue({
+ swapBuyAmountEnabled: true,
+ })
+ jest.mocked(getMultichainFeatures).mockReturnValue({
+ showSwap: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']],
+ showBalances: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']],
+ })
+ jest.mocked(getDynamicConfigParams).mockReturnValue({
+ maxSlippagePercentage: '0.3',
+ popularTokenIds: [],
+ links: {
+ transactionFeesLearnMore: mockTxFeesLearnMoreUrl,
+ },
+ })
+
+ const originalReadContract = publicClient.celo.readContract
+ jest.spyOn(publicClient.celo, 'readContract').mockImplementation(async (args) => {
+ if (args.functionName === 'allowance') {
+ return 0
+ }
+ return originalReadContract(args)
+ })
+ })
+
+ it('should display the correct elements on load', () => {
+ const { getByText, swapFromContainer, swapToContainer } = renderScreen({})
+
+ expect(getByText('swapScreen.title')).toBeTruthy()
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+
+ expect(within(swapFromContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+ expect(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect')).toBeTruthy()
+
+ expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+ expect(within(swapToContainer).getByTestId('SwapAmountInput/TokenSelect')).toBeTruthy()
+ })
+
+ it('should display the UK compliant variants', () => {
+ const mockedPopularTokens = [mockUSDCTokenId, mockPoofTokenId]
+ jest.mocked(getDynamicConfigParams).mockReturnValue({
+ popularTokenIds: mockedPopularTokens,
+ maxSlippagePercentage: '0.3',
+ })
+ jest
+ .mocked(getFeatureGate)
+ .mockImplementation((gate) => gate === StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT)
+
+ const { getByText, tokenBottomSheets } = renderScreen({})
+
+ expect(getByText('swapScreen.confirmSwap, {"context":"UK"}')).toBeTruthy()
+ expect(getByText('swapScreen.disclaimer, {"context":"UK"}')).toBeTruthy()
+ // popular token filter chip is not shown
+ expect(within(tokenBottomSheets[0]).queryByText('tokenBottomSheet.filters.popular')).toBeFalsy()
+ expect(within(tokenBottomSheets[1]).queryByText('tokenBottomSheet.filters.popular')).toBeFalsy()
+ })
+
+ it('should display the token set via fromTokenId prop', () => {
+ const { swapFromContainer, swapToContainer } = renderScreen({ fromTokenId: mockCeurTokenId })
+
+ expect(
+ within(swapFromContainer).getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"cEUR","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
+ expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+ })
+
+ it('should allow selecting tokens', async () => {
+ const { swapFromContainer, swapToContainer, swapScreen } = renderScreen({})
+
+ expect(within(swapFromContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+ expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+
+ const commonAnalyticsProps = {
+ areSwapTokensShuffled: false,
+ fromTokenId: 'celo-alfajores:native',
+ fromTokenNetworkId: 'celo-alfajores',
+ fromTokenSymbol: 'CELO',
+ switchedNetworkId: false,
+ tokenNetworkId: 'celo-alfajores',
+ }
+ expect(AppAnalytics.track).toHaveBeenCalledWith(SwapEvents.swap_screen_confirm_token, {
+ ...commonAnalyticsProps,
+ fieldType: 'FROM',
+ tokenId: 'celo-alfajores:native',
+ tokenPositionInList: 1,
+ tokenSymbol: 'CELO',
+ })
+ expect(AppAnalytics.track).toHaveBeenCalledWith(SwapEvents.swap_screen_confirm_token, {
+ ...commonAnalyticsProps,
+ fieldType: 'TO',
+ tokenId: 'celo-alfajores:0x874069fa1eb16d44d622f2e0ca25eea172369bc1',
+ tokenPositionInList: 2,
+ tokenSymbol: 'cUSD',
+ toTokenId: 'celo-alfajores:0x874069fa1eb16d44d622f2e0ca25eea172369bc1',
+ toTokenNetworkId: 'celo-alfajores',
+ toTokenSymbol: 'cUSD',
+ })
+ })
+
+ it('should show only the allowed to and from tokens', async () => {
+ const { swapFromContainer, swapToContainer, tokenBottomSheets } = renderScreen({
+ isPoofSwappable: false,
+ poofBalance: '0',
+ })
+ const [fromTokenBottomSheet, toTokenBottomSheet] = tokenBottomSheets
+
+ fireEvent.press(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect'))
+
+ expect(within(fromTokenBottomSheet).getByText('Celo Dollar')).toBeTruthy()
+ // should see TT even though it is marked as not swappable, because there is a balance
+ expect(within(fromTokenBottomSheet).getByText('Test Token')).toBeTruthy()
+ // should see not see POOF because it is marked as not swappable and there is no balance
+ expect(within(fromTokenBottomSheet).queryByText('Poof Governance Token')).toBeFalsy()
+
+ // finish the token selection
+ fireEvent.press(within(fromTokenBottomSheet).getByText('Celo Dollar'))
+ expect(
+ within(swapFromContainer).getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"cUSD","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
+
+ fireEvent.press(within(swapToContainer).getByTestId('SwapAmountInput/TokenSelect'))
+
+ expect(within(toTokenBottomSheet).getByText('Celo Dollar')).toBeTruthy()
+ expect(within(toTokenBottomSheet).queryByText('Test Token')).toBeFalsy()
+ expect(within(toTokenBottomSheet).queryByText('Poof Governance Token')).toBeFalsy()
+ })
+
+ it('should not select a token without usd price if the user dismisses the warning', async () => {
+ const { swapToContainer, queryByText, getByText, tokenBottomSheets } = renderScreen({})
+ const tokenBottomSheet = tokenBottomSheets[1] // "from" token selection
+
+ fireEvent.press(within(swapToContainer).getByTestId('SwapAmountInput/TokenSelect'))
+ fireEvent.press(
+ within(tokenBottomSheet).getByText(mockStoreTokenBalances[mockPoofTokenId].name)
+ )
+
+ expect(
+ getByText(
+ 'swapScreen.noUsdPriceWarning.description, {"localCurrency":"PHP","tokenSymbol":"POOF"}'
+ )
+ ).toBeTruthy()
+
+ fireEvent.press(getByText('swapScreen.noUsdPriceWarning.ctaDismiss'))
+
+ expect(
+ queryByText(
+ 'swapScreen.noUsdPriceWarning.description, {"localCurrency":"PHP","tokenSymbol":"POOF"}'
+ )
+ ).toBeFalsy()
+ expect(tokenBottomSheet).toBeVisible()
+ expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+ expect(AppAnalytics.track).not.toHaveBeenCalledWith(
+ SwapEvents.swap_screen_confirm_token,
+ expect.anything()
+ )
+ })
+
+ it('should swap the to/from tokens if the same token is selected', async () => {
+ const { swapFromContainer, swapToContainer, swapScreen } = renderScreen({})
+
+ selectSingleSwapToken(swapFromContainer, 'CELO', swapScreen, Field.FROM)
+ selectSingleSwapToken(swapToContainer, 'cUSD', swapScreen, Field.TO)
+ selectSingleSwapToken(swapFromContainer, 'cUSD', swapScreen, Field.FROM)
+
+ expect(
+ within(swapFromContainer).getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"cUSD","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
+ expect(
+ within(swapToContainer).getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"CELO","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
+ })
+
+ it('should swap the to/from tokens even if the to token was not selected', async () => {
+ const { swapFromContainer, swapToContainer, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'CELO', swapScreen)
+
+ expect(within(swapFromContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy()
+ expect(
+ within(swapToContainer).getByText(
+ 'tokenEnterAmount.tokenDescription, {"tokenName":"CELO","tokenNetwork":"Celo Alfajores"}'
+ )
+ ).toBeTruthy()
+ })
+
+ it('should keep the to amount in sync with the exchange rate', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapFromContainer, swapToContainer, swapScreen, getByText, getByTestId } = renderScreen(
+ {}
+ )
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.234'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1.23456 cUSD'
+ )
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('1.234')
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/ExchangeAmount')
+ ).toHaveTextContent(`${APPROX_SYMBOL} ₱21.43`)
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('1.5234566652')
+ expect(within(swapToContainer).getByTestId('SwapAmountInput/ExchangeAmount')).toHaveTextContent(
+ `${APPROX_SYMBOL} ₱2.03`
+ )
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ })
+
+ it('should display a loader when initially fetching exchange rate', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapScreen, swapFromContainer, swapToContainer, getByText, getByTestId } = renderScreen(
+ {}
+ )
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.234'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(mockFetch.mock.calls.length).toEqual(1)
+ expect(mockFetch.mock.calls[0][0]).toEqual(
+ `${
+ networkConfig.getSwapQuoteUrl
+ }?buyToken=${mockCusdAddress}&buyIsNative=false&buyNetworkId=${
+ NetworkId['celo-alfajores']
+ }&sellToken=${mockCeloAddress}&sellIsNative=true&sellNetworkId=${
+ NetworkId['celo-alfajores']
+ }&sellAmount=1234000000000000000&userAddress=${mockAccount.toLowerCase()}&slippagePercentage=0.3`
+ )
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1.23456 cUSD'
+ )
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('1.234')
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('1.5234566652')
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ })
+
+ it('should allow selecting cross-chain tokens and show cross-chain message', async () => {
+ jest
+ .mocked(getFeatureGate)
+ .mockImplementation((gate) => gate === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS)
+
+ const { getByText, queryByText, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'USDC', swapScreen)
+ expect(
+ queryByText('swapScreen.switchedToNetworkWarning.title, {"networkName":"Ethereum Sepolia"}')
+ ).toBeFalsy()
+
+ expect(getByText('swapScreen.crossChainNotification')).toBeTruthy()
+ })
+
+ it("should show warning on cross-chain swap when user can't afford cross-chain fees and swapping fee currency", async () => {
+ jest
+ .mocked(getFeatureGate)
+ .mockImplementation((gate) => gate === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS)
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ swapType: 'cross-chain',
+ sellAmount: new BigNumber(10).times(new BigNumber(10).pow(18)).toString(),
+ maxCrossChainFee: new BigNumber(10).pow(18).toString(),
+ },
+ })
+ )
+
+ const { getByText, swapScreen, swapFromContainer } = renderScreen({
+ celoBalance: '10',
+ })
+ selectSwapTokens('CELO', 'USDC', swapScreen)
+
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '10'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ expect(
+ getByText(
+ 'swapScreen.crossChainFeeWarning.body, {"networkName":"Celo Alfajores","tokenSymbol":"CELO","tokenAmount":"1"}'
+ )
+ ).toBeTruthy()
+ })
+
+ it("should show warning on cross-chain swap when user can't afford cross-chain fees and swapping non-fee currency", async () => {
+ jest
+ .mocked(getFeatureGate)
+ .mockImplementation((gate) => gate === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS)
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ swapType: 'cross-chain',
+ sellAmount: new BigNumber(10).times(new BigNumber(10).pow(18)).toString(),
+ maxCrossChainFee: new BigNumber(10).pow(18).toString(),
+ },
+ })
+ )
+
+ const { getByText, swapScreen, swapFromContainer } = renderScreen({
+ celoBalance: '0',
+ cUSDBalance: '10',
+ })
+ selectSwapTokens('cUSD', 'USDC', swapScreen)
+
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '10'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ expect(
+ getByText(
+ 'swapScreen.crossChainFeeWarning.body, {"networkName":"Celo Alfajores","tokenSymbol":"CELO","tokenAmount":"1"}'
+ )
+ ).toBeTruthy()
+ })
+
+ it('should allow cross-chain swap when user can pay for cross-chain fee', async () => {
+ jest
+ .mocked(getFeatureGate)
+ .mockImplementation((gate) => gate === StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS)
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ swapType: 'cross-chain',
+ sellAmount: new BigNumber(10).times(new BigNumber(10).pow(18)).toString(),
+ maxCrossChainFee: new BigNumber(10).pow(18).toString(),
+ },
+ })
+ )
+
+ const { getByText, queryByText, swapScreen, swapFromContainer } = renderScreen({
+ celoBalance: '10',
+ })
+ selectSwapTokens('CELO', 'USDC', swapScreen)
+
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '5'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ expect(queryByText('swapScreen.crossChainFeeWarning.title')).toBeFalsy()
+ })
+
+ it('should show and hide the price impact warning', async () => {
+ // mock priceUsd data: CELO price ~$13, cUSD price = $1
+ const lowPriceImpactPrice = '13.12345' // within 4% price impact
+ const highPriceImpactPrice = '12.44445' // more than 4% price impact
+
+ const lowPriceImpact = '1.88' // within 4% price impact
+ const highPriceImpact = '5.2' // more than 4% price impact
+
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ price: highPriceImpactPrice,
+ estimatedPriceImpact: highPriceImpact,
+ },
+ })
+ )
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ price: lowPriceImpactPrice,
+ estimatedPriceImpact: lowPriceImpact,
+ },
+ })
+ )
+
+ const { swapFromContainer, swapScreen, getByText, queryByText, getByTestId } = renderScreen({
+ celoBalance: '1000000',
+ })
+
+ // select 100000 CELO to cUSD swap
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '100000'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 12.44445 cUSD'
+ )
+ expect(getByText('swapScreen.priceImpactWarning.title')).toBeTruthy()
+ expect(AppAnalytics.track).toHaveBeenCalledWith(
+ SwapEvents.swap_price_impact_warning_displayed,
+ {
+ toToken: mockCusdAddress,
+ toTokenId: mockCusdTokenId,
+ toTokenNetworkId: NetworkId['celo-alfajores'],
+ toTokenIsImported: false,
+ fromToken: mockCeloAddress,
+ fromTokenId: mockCeloTokenId,
+ fromTokenNetworkId: NetworkId['celo-alfajores'],
+ fromTokenIsImported: false,
+ amount: '100000',
+ amountType: 'sellAmount',
+ priceImpact: '5.2',
+ provider: 'someProvider',
+ }
+ )
+
+ // select 100 CELO to cUSD swap
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '100'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 13.12345 cUSD'
+ )
+ expect(queryByText('swapScreen.priceImpactWarning.title')).toBeFalsy()
+ })
+
+ it('should show and hide the missing price impact warning', async () => {
+ const lowPriceImpactPrice = '13.12345'
+ const highPriceImpactPrice = '12.44445'
+
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ price: highPriceImpactPrice,
+ estimatedPriceImpact: null,
+ },
+ })
+ )
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ price: lowPriceImpactPrice,
+ estimatedPriceImpact: '2.3',
+ },
+ })
+ )
+
+ const { swapFromContainer, swapScreen, getByText, queryByText, getByTestId } = renderScreen({
+ celoBalance: '1000000',
+ })
+
+ // select 100000 CELO to cUSD swap
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '100000'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 12.44445 cUSD'
+ )
+ expect(getByText('swapScreen.missingSwapImpactWarning.title')).toBeTruthy()
+ expect(AppAnalytics.track).toHaveBeenCalledWith(
+ SwapEvents.swap_price_impact_warning_displayed,
+ {
+ toToken: mockCusdAddress,
+ toTokenId: mockCusdTokenId,
+ toTokenNetworkId: NetworkId['celo-alfajores'],
+ toTokenIsImported: false,
+ fromToken: mockCeloAddress,
+ fromTokenId: mockCeloTokenId,
+ fromTokenNetworkId: NetworkId['celo-alfajores'],
+ fromTokenIsImported: false,
+ amount: '100000',
+ amountType: 'sellAmount',
+ priceImpact: null,
+ provider: 'someProvider',
+ }
+ )
+
+ // select 100 CELO to cUSD swap
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '100'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 13.12345 cUSD'
+ )
+ expect(queryByText('swapScreen.missingSwapImpactWarning.title')).toBeFalsy()
+ })
+
+ it('should prioritise showing the no priceUsd warning when there is also a high price impact', async () => {
+ mockFetch.mockResponseOnce(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ estimatedPriceImpact: 5, // above warning threshold
+ },
+ })
+ )
+
+ const { swapFromContainer, swapScreen, getByText, queryByText, getByTestId } = renderScreen({
+ celoBalance: '100000',
+ })
+
+ selectSwapTokens('CELO', 'POOF', swapScreen) // no priceUsd
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '100'
+ )
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1.23456 POOF'
+ )
+
+ expect(getByText('swapScreen.noUsdPriceWarning.title, {"localCurrency":"PHP"}')).toBeTruthy()
+ expect(queryByText('swapScreen.priceImpactWarning.title')).toBeFalsy()
+ expect(queryByText('swapScreen.missingSwapImpactWarning.title')).toBeFalsy()
+ })
+
+ it('should support from amount with comma as the decimal separator', async () => {
+ // This only changes the display format, the input is parsed with getNumberFormatSettings
+ BigNumber.config({
+ FORMAT: {
+ decimalSeparator: ',',
+ },
+ })
+ mockGetNumberFormatSettings.mockReturnValue({ decimalSeparator: ',' })
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapScreen, swapFromContainer, swapToContainer, getByText, getByTestId } = renderScreen(
+ {}
+ )
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1,234'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(mockFetch.mock.calls.length).toEqual(1)
+ expect(mockFetch.mock.calls[0][0]).toEqual(
+ `${
+ networkConfig.getSwapQuoteUrl
+ }?buyToken=${mockCusdAddress}&buyIsNative=false&buyNetworkId=${
+ NetworkId['celo-alfajores']
+ }&sellToken=${mockCeloAddress}&sellIsNative=true&sellNetworkId=${
+ NetworkId['celo-alfajores']
+ }&sellAmount=1234000000000000000&userAddress=${mockAccount.toLowerCase()}&slippagePercentage=0.3`
+ )
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1,23456 cUSD'
+ )
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('1,234')
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/ExchangeAmount')
+ ).toHaveTextContent(`${APPROX_SYMBOL} ₱21,43`)
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('1,5234566652')
+ expect(within(swapToContainer).getByTestId('SwapAmountInput/ExchangeAmount')).toHaveTextContent(
+ `${APPROX_SYMBOL} ₱2,03`
+ )
+ expect(getByTestId('SwapTransactionDetails/Slippage')).toHaveTextContent('0,3%')
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ })
+
+ it.each([
+ // mock store has 10 CELO balance
+ // mock CELO -> cUSD exchange rate is 1.2345678
+ {
+ amountLabel: 'percentage, {"percentage":25}',
+ percentage: 25,
+ expectedFromAmount: '2.5', // 25% of 10
+ expectedToAmount: '3.0864195', // expectedFromAmount * exchange rate = 2.5 * 1.2345678
+ },
+ {
+ amountLabel: 'percentage, {"percentage":50}',
+ percentage: 50,
+ expectedFromAmount: '5',
+ expectedToAmount: '6.172839',
+ },
+ {
+ amountLabel: 'percentage, {"percentage":75}',
+ percentage: 75,
+ expectedFromAmount: '7.5',
+ expectedToAmount: '9.2592585',
+ },
+ {
+ amountLabel: 'maxSymbol',
+ percentage: 100,
+ expectedFromAmount: '10',
+ expectedToAmount: '12.345678',
+ },
+ ])(
+ 'sets the expected amount when the $amountLabel chip is selected',
+ async ({ amountLabel, percentage, expectedToAmount, expectedFromAmount }) => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapFromContainer, swapToContainer, getByText, getByTestId, swapScreen } =
+ renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+
+ await act(() => {
+ DeviceEventEmitter.emit('keyboardDidShow', { endCoordinates: { height: 100 } })
+ })
+
+ fireEvent.press(within(getByTestId('SwapEnterAmount/AmountOptions')).getByText(amountLabel))
+
+ await waitFor(() =>
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1.23456 cUSD'
+ )
+ )
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(expectedFromAmount)
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(expectedToAmount)
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ }
+ )
+
+ it('should show and hide the max warning for fee currencies', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapFromContainer, getByText, queryByTestId, swapScreen } = renderScreen({
+ celoBalance: '0',
+ cUSDBalance: '10',
+ }) // so that cUSD is the only feeCurrency with a balance
+
+ selectSingleSwapToken(swapFromContainer, 'cUSD', swapScreen, Field.FROM)
+ await selectMaxFromAmount(swapScreen)
+ await waitFor(() =>
+ expect(
+ getByText('swapScreen.maxSwapAmountWarning.bodyV1_74, {"tokenSymbol":"cUSD"}')
+ ).toBeTruthy()
+ )
+
+ fireEvent.press(getByText('swapScreen.maxSwapAmountWarning.learnMore'))
+ expect(navigate).toHaveBeenCalledWith(Screens.WebViewScreen, {
+ uri: mockTxFeesLearnMoreUrl,
+ })
+
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.234'
+ )
+ await waitFor(() => expect(queryByTestId('MaxSwapAmountWarning')).toBeFalsy())
+ })
+
+ it("shouldn't show the max warning when there's balance for more than 1 fee currency", async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapFromContainer, queryByTestId, swapScreen } = renderScreen({
+ celoBalance: '10',
+ cUSDBalance: '20',
+ })
+
+ selectSingleSwapToken(swapFromContainer, 'CELO', swapScreen, Field.FROM)
+ await selectMaxFromAmount(swapScreen)
+ await waitFor(() => expect(queryByTestId('MaxSwapAmountWarning')).toBeFalsy())
+ })
+
+ it('should fetch the quote if the amount is cleared and re-entered', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapFromContainer, swapToContainer, getByText, getByTestId, swapScreen } = renderScreen(
+ {}
+ )
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(mockFetch.mock.calls.length).toEqual(1)
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ ''
+ )
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('')
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('')
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ expect(mockFetch.mock.calls.length).toEqual(1)
+
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1.23456 cUSD'
+ )
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(
+ '10' // matching the value inside the mocked store
+ )
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('12.345678')
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ expect(mockFetch.mock.calls.length).toEqual(2)
+ })
+
+ it('should set max value if it is zero', async () => {
+ const { swapFromContainer, swapToContainer, getByText, swapScreen } = renderScreen({
+ celoBalance: '0',
+ cUSDBalance: '0',
+ })
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('0')
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe('')
+ expect(mockFetch).not.toHaveBeenCalled()
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ })
+
+ it('should display an error banner if api request fails', async () => {
+ mockFetch.mockReject(new Error('Failed to fetch'))
+
+ const { swapFromContainer, getByText, store, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.234'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED)])
+ )
+ })
+
+ it('should display an unsupported notification if quote is not available', async () => {
+ mockFetch.mockReject(new Error(NO_QUOTE_ERROR_MESSAGE))
+
+ const { swapFromContainer, getByText, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.234'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+ expect(getByText('swapScreen.unsupportedTokensWarning.title')).toBeTruthy()
+ })
+
+ it('should be able to start a swap', async () => {
+ const quoteReceivedTimestamp = 1000
+ jest.spyOn(Date, 'now').mockReturnValue(quoteReceivedTimestamp) // quote received timestamp
+
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByText, store, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([
+ swapStart({
+ swapId: expect.any(String),
+ quote: {
+ preparedTransactions,
+ receivedAt: quoteReceivedTimestamp,
+ price: defaultQuote.unvalidatedSwapTransaction.price,
+ appFeePercentageIncludedInPrice:
+ defaultQuote.unvalidatedSwapTransaction.appFeePercentageIncludedInPrice,
+ provider: defaultQuote.details.swapProvider,
+ estimatedPriceImpact: defaultQuote.unvalidatedSwapTransaction.estimatedPriceImpact,
+ allowanceTarget: defaultQuote.unvalidatedSwapTransaction.allowanceTarget,
+ swapType: 'same-chain',
+ },
+ userInput: {
+ toTokenId: mockCusdTokenId,
+ fromTokenId: mockCeloTokenId,
+ swapAmount: {
+ [Field.FROM]: '10',
+ [Field.TO]: '12.345678', // 10 * 1.2345678
+ },
+ updatedField: Field.FROM,
+ },
+ areSwapTokensShuffled: false,
+ }),
+ ])
+ )
+ })
+
+ it('should start the swap without an approval transaction if the allowance is high enough', async () => {
+ jest.spyOn(publicClient.celo, 'readContract').mockResolvedValueOnce(BigInt(11 * 1e18)) // greater than swap amount of 10
+ mockFetch.mockResponse(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ buyTokenAddress: mockCeloAddress,
+ sellTokenAddress: mockCusdAddress,
+ },
+ })
+ )
+ const { getByText, store, swapScreen, swapFromContainer } = renderScreen({})
+
+ selectSwapTokens('cUSD', 'CELO', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '10'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([
+ swapStart({
+ swapId: expect.any(String),
+ quote: {
+ preparedTransactions: [preparedTransactions[1]], // no approval transaction
+ receivedAt: expect.any(Number),
+ price: defaultQuote.unvalidatedSwapTransaction.price,
+ appFeePercentageIncludedInPrice:
+ defaultQuote.unvalidatedSwapTransaction.appFeePercentageIncludedInPrice,
+ provider: defaultQuote.details.swapProvider,
+ estimatedPriceImpact: defaultQuote.unvalidatedSwapTransaction.estimatedPriceImpact,
+ allowanceTarget: defaultQuote.unvalidatedSwapTransaction.allowanceTarget,
+ swapType: 'same-chain',
+ },
+ userInput: {
+ toTokenId: mockCeloTokenId,
+ fromTokenId: mockCusdTokenId,
+ swapAmount: {
+ [Field.FROM]: '10',
+ [Field.TO]: '12.345678', // 10 * 1.2345678
+ },
+ updatedField: Field.FROM,
+ },
+ areSwapTokensShuffled: false,
+ }),
+ ])
+ )
+ })
+
+ it('should be able to start a swap when the entered value uses comma as the decimal separator', async () => {
+ const quoteReceivedTimestamp = 1000
+ jest.spyOn(Date, 'now').mockReturnValue(quoteReceivedTimestamp) // quote received timestamp
+
+ mockGetNumberFormatSettings.mockReturnValue({ decimalSeparator: ',' })
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { swapScreen, swapFromContainer, getByText, store } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.5'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ expect(store.getActions()).toEqual(
+ expect.arrayContaining([
+ swapStart({
+ swapId: expect.any(String),
+ quote: {
+ preparedTransactions,
+ receivedAt: quoteReceivedTimestamp,
+ price: defaultQuote.unvalidatedSwapTransaction.price,
+ appFeePercentageIncludedInPrice:
+ defaultQuote.unvalidatedSwapTransaction.appFeePercentageIncludedInPrice,
+ provider: defaultQuote.details.swapProvider,
+ estimatedPriceImpact: defaultQuote.unvalidatedSwapTransaction.estimatedPriceImpact,
+ allowanceTarget: defaultQuote.unvalidatedSwapTransaction.allowanceTarget,
+ swapType: 'same-chain',
+ },
+ userInput: {
+ toTokenId: mockCusdTokenId,
+ fromTokenId: mockCeloTokenId,
+ swapAmount: {
+ [Field.FROM]: '1.5',
+ [Field.TO]: '1.8518517', // 1.5 * 1.2345678
+ },
+ updatedField: Field.FROM,
+ },
+ areSwapTokensShuffled: false,
+ }),
+ ])
+ )
+ })
+
+ it('should have correct analytics on swap submission', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByText, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+
+ // Clear any previous events
+ jest.mocked(AppAnalytics.track).mockClear()
+
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+ expect(AppAnalytics.track).toHaveBeenCalledWith(SwapEvents.swap_review_submit, {
+ toToken: mockCusdAddress,
+ toTokenId: mockCusdTokenId,
+ toTokenNetworkId: NetworkId['celo-alfajores'],
+ toTokenIsImported: false,
+ fromToken: mockCeloAddress,
+ fromTokenId: mockCeloTokenId,
+ fromTokenNetworkId: NetworkId['celo-alfajores'],
+ fromTokenIsImported: false,
+ amount: '10',
+ amountType: 'sellAmount',
+ allowanceTarget: defaultQuote.unvalidatedSwapTransaction.allowanceTarget,
+ estimatedPriceImpact: defaultQuote.unvalidatedSwapTransaction.estimatedPriceImpact,
+ price: defaultQuote.unvalidatedSwapTransaction.price,
+ provider: defaultQuote.details.swapProvider,
+ web3Library: 'viem',
+ gas: 1821000,
+ maxGasFee: 0.021852,
+ maxGasFeeUsd: 0.28529642665785426,
+ estimatedGasFee: 0.014568,
+ estimatedGasFeeUsd: 0.19019761777190283,
+ feeCurrency: undefined,
+ feeCurrencySymbol: 'CELO',
+ txCount: 2,
+ swapType: 'same-chain',
+ })
+ })
+
+ it('should show swappable tokens and search box', async () => {
+ const { swapToContainer, swapFromContainer, swapScreen, tokenBottomSheets } = renderScreen({})
+ const tokenBottomSheet = tokenBottomSheets[1] // "to" token selection
+
+ selectSingleSwapToken(swapFromContainer, 'CELO', swapScreen, Field.FROM)
+ fireEvent.press(within(swapToContainer).getByTestId('SwapAmountInput/TokenSelect'))
+
+ expect(
+ within(tokenBottomSheet).getByPlaceholderText('tokenBottomSheet.searchAssets')
+ ).toBeTruthy()
+
+ expect(within(tokenBottomSheet).getByText('Celo Dollar')).toBeTruthy()
+ expect(within(tokenBottomSheet).getByText('Celo Euro')).toBeTruthy()
+ expect(within(tokenBottomSheet).getByText('Celo native asset')).toBeTruthy()
+ expect(within(tokenBottomSheet).getByText('Poof Governance Token')).toBeTruthy()
+ expect(within(tokenBottomSheet).queryByText('Test Token')).toBeFalsy()
+ })
+
+ it('should not show input sections if tokens are not selected', () => {
+ jest.mocked(getExperimentParams).mockReturnValue({
+ swapBuyAmountEnabled: false,
+ })
+ const { swapFromContainer, swapToContainer } = renderScreen({})
+
+ expect(within(swapFromContainer).queryByTestId('SwapAmountInput/TokenAmountInput')).toBeFalsy()
+ expect(within(swapToContainer).queryByTestId('SwapAmountInput/TokenAmountInput')).toBeFalsy()
+ })
+
+ it('should be able to switch tokens by pressing arrow button', async () => {
+ jest.mocked(getExperimentParams).mockReturnValue({
+ swapBuyAmountEnabled: false,
+ })
+ const { swapFromContainer, swapToContainer, swapScreen, getByTestId } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+
+ expect(within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy()
+ expect(within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy()
+
+ fireEvent.press(getByTestId('SwapScreen/SwitchTokens'))
+
+ expect(within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy()
+ expect(within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy()
+ })
+
+ it('should disable editing of the buy token amount', () => {
+ const { swapFromContainer, swapToContainer, swapScreen } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.editable
+ ).toBe(true)
+ expect(
+ within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.editable
+ ).toBe(false)
+ })
+
+ it('should display the correct transaction details', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByTestId, swapFromContainer, swapScreen } = renderScreen({
+ celoBalance: '10',
+ cUSDBalance: '10',
+ })
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '2'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ const transactionDetails = getByTestId('SwapTransactionDetails')
+ expect(transactionDetails).toHaveTextContent('swapScreen.transactionDetails.fee')
+ // matches mocked value (0.015 CELO) provided to estimateFeesPerGas, estimateGas, and gas in defaultQuoteResponse
+ expect(getByTestId('SwapTransactionDetails/Fees')).toHaveTextContent('≈ ₱0.25')
+ expect(transactionDetails).toHaveTextContent('swapScreen.transactionDetails.slippagePercentage')
+ expect(getByTestId('SwapTransactionDetails/Slippage')).toHaveTextContent('0.3%')
+ })
+
+ it('should disable the confirm button after a swap has been submitted', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { update, getByText, getByTestId, swapScreen, store } = renderScreen({})
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ const swapAction = store.getActions().find((action) => action.type === swapStart.type)
+ const swapId = swapAction.payload.swapId
+ expect(swapId).toBeTruthy()
+
+ // Simulate swap in progress
+ const state = store.getState()
+ const updatedStore = createMockStore({
+ ...state,
+ swap: {
+ ...state.swap,
+ currentSwap: {
+ id: swapId,
+ status: 'started',
+ },
+ },
+
+ // as per test/utils.ts, line 105
+ transactionFeedV2Api: undefined,
+ })
+
+ update(
+
+
+
+ )
+
+ // Using testID because the button is in loading state, not showing the text
+ expect(getByTestId('ConfirmSwapButton')).toBeDisabled()
+ })
+
+ it('should show and hide the error warning', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { update, getByText, queryByText, swapFromContainer, swapScreen, store } = renderScreen(
+ {}
+ )
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ const swapAction = store.getActions().find((action) => action.type === swapStart.type)
+ const swapId = swapAction.payload.swapId
+ expect(swapId).toBeTruthy()
+
+ expect(queryByText('swapScreen.confirmSwapFailedWarning.title')).toBeFalsy()
+ expect(queryByText('swapScreen.confirmSwapFailedWarning.body')).toBeFalsy()
+
+ // Simulate swap error
+ const state = store.getState()
+ const updatedStore = createMockStore({
+ ...state,
+ swap: {
+ ...state.swap,
+ currentSwap: {
+ id: swapId,
+ status: 'error',
+ },
+ },
+
+ // as per test/utils.ts, line 105
+ transactionFeedV2Api: undefined,
+ })
+
+ update(
+
+
+
+ )
+
+ expect(getByText('swapScreen.confirmSwapFailedWarning.title')).toBeTruthy()
+ expect(getByText('swapScreen.confirmSwapFailedWarning.body')).toBeTruthy()
+ // NOT disabled, so users can retry
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+
+ // Now change some input, and the warning should disappear
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '2'
+ )
+
+ expect(queryByText('swapScreen.confirmSwapFailedWarning.title')).toBeFalsy()
+ expect(queryByText('swapScreen.confirmSwapFailedWarning.body')).toBeFalsy()
+ })
+
+ it('should show and hide the switched network warning if cross chain swaps disabled', async () => {
+ mockFetch.mockResponse(defaultQuoteResponse)
+ jest.mocked(getFeatureGate).mockImplementation(() => false)
+ const {
+ getByText,
+ getByTestId,
+ queryByTestId,
+ swapToContainer,
+ swapFromContainer,
+ swapScreen,
+ } = renderScreen({ cUSDBalance: '0' })
+
+ // First get a quote for a network
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent(
+ '1 CELO ≈ 1.23456 cUSD'
+ )
+ expect(queryByTestId('SwitchedToNetworkWarning')).toBeFalsy()
+ expect(getByTestId('MaxSwapAmountWarning')).toBeTruthy()
+
+ // Now select a "to" token from a different network, the warning should appear
+ selectSingleSwapToken(swapToContainer, 'USDC', swapScreen, Field.TO)
+
+ expect(
+ getByText('swapScreen.switchedToNetworkWarning.title, {"networkName":"Ethereum Sepolia"}')
+ ).toBeTruthy()
+ expect(
+ getByText(
+ 'swapScreen.switchedToNetworkWarning.body, {"networkName":"Ethereum Sepolia","context":"swapFrom"}'
+ )
+ ).toBeTruthy()
+
+ // Make sure the max warning is not shown
+ expect(queryByTestId('MaxSwapAmountWarning')).toBeFalsy()
+
+ // Check the quote is cleared
+ expect(queryByTestId('SwapTransactionDetails/ExchangeRate')).toBeFalsy()
+
+ // Disabled, until the user selects a token from the same network
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+
+ // Now select a "from" token from the same network, the warning should disappear
+ selectSingleSwapToken(swapFromContainer, 'ETH', swapScreen, Field.FROM)
+
+ expect(queryByTestId('SwitchedToNetworkWarning')).toBeFalsy()
+ // Max warning is shown again, because both ETH and CELO have the same balance
+ // and we previously selected the max value for CELO
+ expect(queryByTestId('MaxSwapAmountWarning')).toBeTruthy()
+
+ // Now select a "from" token from a different network again, the warning should reappear
+ selectSingleSwapToken(swapFromContainer, 'cUSD', swapScreen, Field.FROM)
+
+ expect(
+ getByText('swapScreen.switchedToNetworkWarning.title, {"networkName":"Celo Alfajores"}')
+ ).toBeTruthy()
+ expect(
+ getByText(
+ 'swapScreen.switchedToNetworkWarning.body, {"networkName":"Celo Alfajores","context":"swapTo"}'
+ )
+ ).toBeTruthy()
+ })
+
+ it("should warn when the balances for feeCurrencies are 0 and can't cover the fee", async () => {
+ // Swap from POOF to CELO, when no feeCurrency has any balance
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByText, swapScreen } = renderScreen({
+ celoBalance: '0',
+ cUSDBalance: '0',
+ })
+
+ selectSwapTokens('POOF', 'CELO', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+
+ expect(
+ getByText(
+ 'swapScreen.notEnoughBalanceForGas.description, {"feeCurrencies":"CELO, cEUR, cUSD"}'
+ )
+ ).toBeTruthy()
+ })
+
+ it('should warn when the balances for feeCurrencies are too low to cover the fee', async () => {
+ // Swap from POOF to CELO, when no feeCurrency has any balance
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByText, swapScreen } = renderScreen({
+ celoBalance: '0.001',
+ cUSDBalance: '0.001',
+ })
+
+ selectSwapTokens('POOF', 'CELO', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+
+ expect(
+ getByText(
+ 'swapScreen.notEnoughBalanceForGas.description, {"feeCurrencies":"CELO, cUSD, cEUR"} '
+ )
+ ).toBeTruthy()
+ })
+
+ it('should prompt the user to decrease the swap amount when swapping the max amount of a feeCurrency, and no other feeCurrency has enough balance to pay for the fee', async () => {
+ // Swap CELO to cUSD, when only CELO has balance
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByText, queryByText, swapScreen, swapFromContainer } = renderScreen({
+ celoBalance: '1.234',
+ cUSDBalance: '0',
+ })
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(
+ '1.234' // matching the value inside the mocked store
+ )
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+
+ const confirmDecrease = getByText('swapScreen.decreaseSwapAmountForGasWarning.cta')
+ expect(confirmDecrease).toBeTruthy()
+
+ // Mock next call with the decreased amount
+ mockFetch.mockResponse(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ sellAmount: '1207057600000000000',
+ },
+ })
+ )
+
+ // Now, decrease the swap amount
+ fireEvent.press(confirmDecrease)
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(
+ '1.2077776' // 1.234 minus the max fee calculated for the swap
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(queryByText('swapScreen.decreaseSwapAmountForGasWarning.cta')).toBeFalsy()
+ })
+
+ it('should prompt the user to decrease the swap amount when swapping close to the max amount of a feeCurrency, and no other feeCurrency has enough balance to pay for the fee', async () => {
+ // Swap CELO to cUSD, when only CELO has balance
+ mockFetch.mockResponse(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ sellAmount: '1233000000000000000', // 1.233
+ },
+ })
+ )
+ const { getByText, queryByText, swapScreen, swapFromContainer } = renderScreen({
+ celoBalance: '1.234',
+ cUSDBalance: '0',
+ })
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1.233'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
+
+ const confirmDecrease = getByText('swapScreen.decreaseSwapAmountForGasWarning.cta')
+ expect(confirmDecrease).toBeTruthy()
+
+ // Mock next call with the decreased amount
+ mockFetch.mockResponse(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ sellAmount: '1207057600000000000',
+ },
+ })
+ )
+
+ // Now, decrease the swap amount
+ fireEvent.press(confirmDecrease)
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(
+ '1.2077776' // 1.234 (max balance) minus the max fee calculated for the swap
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(queryByText('swapScreen.decreaseSwapAmountForGasWarning.cta')).toBeFalsy()
+ })
+
+ it("should allow swapping the entered amount of a feeCurrency when there's enough balance to cover for the fee, while no other feeCurrency can pay for the fee", async () => {
+ // Swap CELO to cUSD, when only CELO has balance
+ mockFetch.mockResponse(
+ JSON.stringify({
+ ...defaultQuote,
+ unvalidatedSwapTransaction: {
+ ...defaultQuote.unvalidatedSwapTransaction,
+ sellAmount: '1000000000000000000', // 1
+ },
+ })
+ )
+ const { getByText, queryByTestId, swapFromContainer, swapScreen } = renderScreen({
+ celoBalance: '1.234',
+ cUSDBalance: '0',
+ })
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ fireEvent.changeText(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'),
+ '1'
+ )
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ expect(queryByTestId('QuoteResultNotEnoughBalanceForGasBottomSheet')).toBeFalsy()
+ expect(queryByTestId('QuoteResultNeedDecreaseSwapAmountForGasBottomSheet')).toBeFalsy()
+ })
+
+ it("should allow swapping the max balance of a feeCurrency when there's another feeCurrency to pay for the fee", async () => {
+ // Swap full CELO balance to cUSD
+ mockFetch.mockResponse(defaultQuoteResponse)
+ const { getByText, queryByTestId, swapScreen, swapFromContainer } = renderScreen({
+ celoBalance: '1.234',
+ cUSDBalance: '10',
+ })
+
+ selectSwapTokens('CELO', 'cUSD', swapScreen)
+ await selectMaxFromAmount(swapScreen)
+
+ await act(() => {
+ jest.runOnlyPendingTimers()
+ })
+
+ expect(
+ within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value
+ ).toBe(
+ '1.234' // matching the value inside the mocked store
+ )
+
+ expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled()
+ fireEvent.press(getByText('swapScreen.confirmSwap'))
+
+ expect(queryByTestId('QuoteResultNotEnoughBalanceForGasBottomSheet')).toBeFalsy()
+ expect(queryByTestId('QuoteResultNeedDecreaseSwapAmountForGasBottomSheet')).toBeFalsy()
+ })
+
+ describe('filter tokens', () => {
+ beforeEach(() => {
+ jest
+ .mocked(getFeatureGate)
+ .mockImplementation((gate) => gate === StatsigFeatureGates.SHOW_SWAP_TOKEN_FILTERS)
+ })
+
+ const expectedAllFromTokens = Object.values(mockStoreTokenBalances).filter(
+ (token) => token.isSwappable !== false || token.balance !== '0' // include unswappable tokens with balance because it is the "from" token
+ )
+ const expectedAllToTokens = Object.values(mockStoreTokenBalances).filter(
+ (token) => token.isSwappable !== false
+ )
+
+ it('should show "my tokens" for the "from" token selection by default', () => {
+ const mockedZeroBalanceTokens = [mockCeurTokenId, mockCusdTokenId, mockPoofTokenId]
+ const expectedTokensWithBalance = expectedAllFromTokens.filter(
+ (token) => !mockedZeroBalanceTokens.includes(token.tokenId)
+ )
+
+ const { swapFromContainer, tokenBottomSheets } = renderScreen({
+ cUSDBalance: '0',
+ poofBalance: '0', // cEUR also has 0 balance in the global mock
+ })
+ const tokenBottomSheet = tokenBottomSheets[0] // "from" token selection
+
+ fireEvent.press(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect'))
+
+ expectedTokensWithBalance.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ const displayedTokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
+ expect(displayedTokens.length).toBe(expectedTokensWithBalance.length)
+
+ // deselect pre-selected filters to show all tokens
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.myTokens'))
+
+ expectedAllFromTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ })
+
+ it('should show "recently swapped" tokens', () => {
+ const mockedLastSwapped = [mockCeurTokenId, mockCusdTokenId, mockPoofTokenId]
+ const expectedLastSwapTokens = expectedAllFromTokens.filter((token) =>
+ mockedLastSwapped.includes(token.tokenId)
+ )
+
+ const { swapFromContainer, tokenBottomSheets } = renderScreen({
+ lastSwapped: mockedLastSwapped,
+ })
+ const tokenBottomSheet = tokenBottomSheets[0] // "from" token selection
+
+ fireEvent.press(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect'))
+ // deselect pre-selected filters to show all tokens
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.myTokens'))
+ // select last swapped filter
+ fireEvent.press(
+ within(tokenBottomSheet).getByText('tokenBottomSheet.filters.recentlySwapped')
+ )
+
+ expectedLastSwapTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ const displayedTokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
+ expect(displayedTokens.length).toBe(expectedLastSwapTokens.length)
+
+ // de-select last swapped filter
+ fireEvent.press(
+ within(tokenBottomSheet).getByText('tokenBottomSheet.filters.recentlySwapped')
+ )
+
+ expectedAllFromTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ })
+
+ it('should show "popular" tokens', () => {
+ const mockedPopularTokens = [mockUSDCTokenId, mockPoofTokenId]
+ jest.mocked(getMultichainFeatures).mockReturnValue({
+ showSwap: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']],
+ showBalances: [NetworkId['celo-alfajores'], NetworkId['ethereum-sepolia']],
+ })
+ jest.mocked(getDynamicConfigParams).mockReturnValue({
+ popularTokenIds: mockedPopularTokens,
+ maxSlippagePercentage: '0.3',
+ })
+ const expectedPopularTokens = expectedAllFromTokens.filter((token) =>
+ mockedPopularTokens.includes(token.tokenId)
+ )
+
+ const { swapFromContainer, tokenBottomSheets } = renderScreen({})
+ const tokenBottomSheet = tokenBottomSheets[0] // "from" token selection
+
+ fireEvent.press(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect'))
+ // deselect pre-selected filters to show all tokens
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.myTokens'))
+ // select popular filter
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.popular'))
+
+ expectedPopularTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ const displayedTokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
+ expect(displayedTokens.length).toBe(expectedPopularTokens.length)
+
+ // de-select filter
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.popular'))
+
+ expectedAllFromTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ })
+
+ it('should show the network filters when there are multiple supported networks', () => {
+ const expectedEthTokens = expectedAllFromTokens.filter(
+ (token) => token.networkId === NetworkId['ethereum-sepolia']
+ )
+ const expectedCeloTokens = expectedAllFromTokens.filter(
+ (token) => token.networkId === NetworkId['celo-alfajores']
+ )
+
+ const { swapFromContainer, tokenBottomSheets, getAllByTestId } = renderScreen({})
+ const tokenBottomSheet = tokenBottomSheets[0] // "from" token selection
+ const networkMultiSelect = getAllByTestId('MultiSelectBottomSheet')[0]
+
+ fireEvent.press(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect'))
+ // deselect pre-selected filters to show all tokens
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.myTokens'))
+
+ // open network bottom sheet
+ fireEvent.press(within(tokenBottomSheet).getByText('tokenBottomSheet.filters.selectNetwork'))
+
+ // select celo filter
+ fireEvent.press(within(networkMultiSelect).getByTestId('Celo Alfajores-icon'))
+
+ expectedCeloTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ expect(within(tokenBottomSheet).getAllByTestId('TokenBalanceItem').length).toBe(
+ expectedCeloTokens.length
+ )
+
+ // select eth filter
+ fireEvent.press(within(networkMultiSelect).getByTestId('Ethereum Sepolia-icon'))
+
+ expectedEthTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ expect(within(tokenBottomSheet).getAllByTestId('TokenBalanceItem').length).toBe(
+ expectedEthTokens.length
+ )
+
+ // select all networks
+ fireEvent.press(within(networkMultiSelect).getByText('multiSelect.allNetworks'))
+
+ expectedCeloTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ expectedEthTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ })
+
+ it('should show pre-selected network filter from route params', async () => {
+ const expectedCeloTokens = expectedAllToTokens.filter(
+ (token) => token.networkId === NetworkId['celo-alfajores']
+ )
+ const { tokenBottomSheets } = renderScreen({
+ toTokenNetworkId: NetworkId['celo-alfajores'],
+ })
+ const tokenBottomSheet = tokenBottomSheets[1] // "to" token selection
+
+ const filteredTokens = within(tokenBottomSheet).getAllByTestId('TokenBalanceItem')
+
+ // only the celo network tokens are displayed
+ expect(filteredTokens.length).toBe(expectedCeloTokens.length)
+ expectedCeloTokens.forEach((token) => {
+ expect(within(tokenBottomSheet).getByText(token.name)).toBeTruthy()
+ })
+ })
+ })
+})
diff --git a/src/swap/SwapScreenV2.tsx b/src/swap/SwapScreenV2.tsx
new file mode 100644
index 00000000000..a48549ec1c0
--- /dev/null
+++ b/src/swap/SwapScreenV2.tsx
@@ -0,0 +1,1088 @@
+import { NativeStackScreenProps } from '@react-navigation/native-stack'
+import BigNumber from 'bignumber.js'
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import { TextInput as RNTextInput, StyleSheet, Text, View } from 'react-native'
+import { ScrollView } from 'react-native-gesture-handler'
+import { SafeAreaView } from 'react-native-safe-area-context'
+import { showError } from 'src/alert/actions'
+import AppAnalytics from 'src/analytics/AppAnalytics'
+import { SwapEvents } from 'src/analytics/Events'
+import { ErrorMessages } from 'src/app/ErrorMessages'
+import BackButton from 'src/components/BackButton'
+import BottomSheet, { BottomSheetModalRefType } from 'src/components/BottomSheet'
+import Button, { BtnSizes, BtnTypes } from 'src/components/Button'
+import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification'
+import Toast from 'src/components/Toast'
+import TokenBottomSheet, { TokenPickerOrigin } from 'src/components/TokenBottomSheet'
+import TokenEnterAmount, {
+ FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS,
+ useEnterAmount,
+} from 'src/components/TokenEnterAmount'
+import Touchable from 'src/components/Touchable'
+import CustomHeader from 'src/components/header/CustomHeader'
+import ArrowDown from 'src/icons/ArrowDown'
+import CircledIcon from 'src/icons/CircledIcon'
+import CrossChainIndicator from 'src/icons/CrossChainIndicator'
+import { getLocalCurrencyCode } from 'src/localCurrency/selectors'
+import { navigate } from 'src/navigator/NavigationService'
+import { Screens } from 'src/navigator/Screens'
+import { StackParamList } from 'src/navigator/types'
+import { useDispatch, useSelector } from 'src/redux/hooks'
+import EnterAmountOptions from 'src/send/EnterAmountOptions'
+import { NETWORK_NAMES } from 'src/shared/conts'
+import { getDynamicConfigParams, getFeatureGate } from 'src/statsig'
+import { DynamicConfigs } from 'src/statsig/constants'
+import { StatsigDynamicConfigs, StatsigFeatureGates } from 'src/statsig/types'
+import colors from 'src/styles/colors'
+import { typeScale } from 'src/styles/fonts'
+import { Spacing } from 'src/styles/styles'
+import variables from 'src/styles/variables'
+import FeeInfoBottomSheet from 'src/swap/FeeInfoBottomSheet'
+import SwapTransactionDetails from 'src/swap/SwapTransactionDetails'
+import getCrossChainFee from 'src/swap/getCrossChainFee'
+import { getSwapTxsAnalyticsProperties } from 'src/swap/getSwapTxsAnalyticsProperties'
+import { currentSwapSelector, priceImpactWarningThresholdSelector } from 'src/swap/selectors'
+import { swapStart } from 'src/swap/slice'
+import { AppFeeAmount, Field, SwapFeeAmount } from 'src/swap/types'
+import useFilterChips from 'src/swap/useFilterChips'
+import useSwapQuote, { NO_QUOTE_ERROR_MESSAGE, QuoteResult } from 'src/swap/useSwapQuote'
+import { useSwappableTokens } from 'src/tokens/hooks'
+import {
+ feeCurrenciesSelector,
+ feeCurrenciesWithPositiveBalancesSelector,
+ tokensByIdSelector,
+} from 'src/tokens/selectors'
+import { TokenBalance } from 'src/tokens/slice'
+import { getSupportedNetworkIdsForSwap } from 'src/tokens/utils'
+import { NetworkId } from 'src/transactions/types'
+import Logger from 'src/utils/Logger'
+import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions'
+import { getSerializablePreparedTransactions } from 'src/viem/preparedTransactionSerialization'
+import networkConfig from 'src/web3/networkConfig'
+import { v4 as uuidv4 } from 'uuid'
+
+const TAG = 'SwapScreen'
+
+function getNetworkFee(quote: QuoteResult | null): SwapFeeAmount | undefined {
+ const { feeCurrency, maxFeeAmount, estimatedFeeAmount } = getFeeCurrencyAndAmounts(
+ quote?.preparedTransactions
+ )
+ return feeCurrency && estimatedFeeAmount
+ ? {
+ token: feeCurrency,
+ maxAmount: maxFeeAmount,
+ amount: estimatedFeeAmount,
+ }
+ : undefined
+}
+
+type Props = NativeStackScreenProps
+
+export default function SwapScreenV2({ route }: Props) {
+ const { t } = useTranslation()
+ const dispatch = useDispatch()
+ const allowCrossChainSwaps = getFeatureGate(StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS)
+ const showUKCompliantVariant = getFeatureGate(StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT)
+ const { swappableFromTokens, swappableToTokens, areSwapTokensShuffled } = useSwappableTokens()
+ const { links } = getDynamicConfigParams(DynamicConfigs[StatsigDynamicConfigs.APP_CONFIG])
+ const { maxSlippagePercentage, enableAppFee } = getDynamicConfigParams(
+ DynamicConfigs[StatsigDynamicConfigs.SWAP_CONFIG]
+ )
+
+ const inputFromRef = useRef(null)
+ const inputToRef = useRef(null)
+ const tokenBottomSheetFromRef = useRef(null)
+ const tokenBottomSheetToRef = useRef(null)
+ const exchangeRateInfoBottomSheetRef = useRef(null)
+ const feeInfoBottomSheetRef = useRef(null)
+ const slippageInfoBottomSheetRef = useRef(null)
+ const estimatedDurationBottomSheetRef = useRef(null)
+
+ const [noUsdPriceToken, setNoUsdPriceToken] = useState<
+ { token: TokenBalance; tokenPositionInList: number } | undefined
+ >(undefined)
+ const [selectedPercentage, setSelectedPercentage] = useState(null)
+ const [startedSwapId, setStartedSwapId] = useState(undefined)
+ const [switchedToNetworkId, setSwitchedToNetworkId] = useState<{
+ networkId: NetworkId
+ field: Field
+ } | null>(null)
+ const [fromToken, setFromToken] = useState(() => {
+ if (!route.params?.fromTokenId) return undefined
+ return swappableFromTokens.find((token) => token.tokenId === route.params!.fromTokenId)
+ })
+ const [toToken, setToToken] = useState(() => {
+ if (!route.params?.toTokenId) return undefined
+ return swappableToTokens.find((token) => token.tokenId === route.params!.toTokenId)
+ })
+
+ const currentSwap = useSelector(currentSwapSelector)
+ const localCurrency = useSelector(getLocalCurrencyCode)
+ const priceImpactWarningThreshold = useSelector(priceImpactWarningThresholdSelector)
+ const tokensById = useSelector((state) =>
+ tokensByIdSelector(state, getSupportedNetworkIdsForSwap())
+ )
+ const crossChainFeeCurrency = useSelector((state) =>
+ feeCurrenciesSelector(state, fromToken?.networkId || networkConfig.defaultNetworkId)
+ ).find((token) => token.isNative)
+ const feeCurrenciesWithPositiveBalances = useSelector((state) =>
+ feeCurrenciesWithPositiveBalancesSelector(
+ state,
+ fromToken?.networkId || networkConfig.defaultNetworkId
+ )
+ )
+
+ const { quote, refreshQuote, fetchSwapQuoteError, fetchingSwapQuote, clearQuote } = useSwapQuote({
+ networkId: fromToken?.networkId || networkConfig.defaultNetworkId,
+ slippagePercentage: maxSlippagePercentage,
+ enableAppFee: enableAppFee,
+ onError: (error) => {
+ if (!error.message.includes(NO_QUOTE_ERROR_MESSAGE)) {
+ dispatch(showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED))
+ }
+ },
+ onSuccess: (newQuote) => {
+ if (!newQuote) {
+ replaceAmountTo('')
+ return
+ }
+
+ if (!processedAmountsFrom.token.bignum) {
+ return
+ }
+
+ const newAmount = processedAmountsFrom.token.bignum
+ .multipliedBy(new BigNumber(newQuote.price))
+ .toString()
+
+ replaceAmountTo(newAmount)
+ },
+ })
+
+ const {
+ amount: amountFrom,
+ amountType,
+ processedAmounts: processedAmountsFrom,
+ handleAmountInputChange,
+ handleToggleAmountType,
+ handleSelectPercentageAmount,
+ } = useEnterAmount({
+ inputRef: inputFromRef,
+ token: fromToken,
+ onHandleAmountInputChange: () => {
+ setSelectedPercentage(null)
+ },
+ })
+
+ const {
+ amount: amountTo,
+ processedAmounts: processedAmountsTo,
+ replaceAmount: replaceAmountTo,
+ } = useEnterAmount({ token: toToken, inputRef: inputToRef })
+
+ const filterChipsFrom = useFilterChips(Field.FROM)
+ const filterChipsTo = useFilterChips(Field.TO, route.params?.toTokenNetworkId)
+ const parsedSlippagePercentage = new BigNumber(maxSlippagePercentage).toFormat()
+ const crossChainFee = getCrossChainFee(quote, crossChainFeeCurrency)
+ const swapStatus = startedSwapId === currentSwap?.id ? currentSwap?.status : null
+ const confirmSwapIsLoading = swapStatus === 'started'
+ const confirmSwapFailed = swapStatus === 'error'
+ const switchedToNetworkName = switchedToNetworkId && NETWORK_NAMES[switchedToNetworkId.networkId]
+ const showCrossChainSwapNotification =
+ toToken && fromToken && toToken.networkId !== fromToken.networkId && allowCrossChainSwaps
+ const feeCurrencies =
+ quote && quote.preparedTransactions.type === 'not-enough-balance-for-gas'
+ ? quote.preparedTransactions.feeCurrencies.map((feeCurrency) => feeCurrency.symbol).join(', ')
+ : ''
+
+ const networkFee = useMemo(() => getNetworkFee(quote), [fromToken, quote])
+ const feeToken = networkFee?.token ? tokensById[networkFee.token.tokenId] : undefined
+
+ const appFee: AppFeeAmount | undefined = useMemo(() => {
+ if (!quote || !fromToken || !processedAmountsFrom.token.bignum) {
+ return undefined
+ }
+
+ const percentage = new BigNumber(quote.appFeePercentageIncludedInPrice || 0)
+
+ return {
+ amount: processedAmountsFrom.token.bignum.multipliedBy(percentage).dividedBy(100),
+ token: fromToken,
+ percentage,
+ }
+ }, [quote, processedAmountsFrom.token.bignum, fromToken])
+
+ const shouldShowSkeletons = useMemo(() => {
+ if (fetchingSwapQuote) return true
+
+ if (
+ quote &&
+ (quote.fromTokenId !== fromToken?.tokenId || quote.toTokenId !== toToken?.tokenId)
+ ) {
+ return true
+ }
+
+ if (
+ quote &&
+ processedAmountsFrom.token.bignum &&
+ !quote.swapAmount.eq(processedAmountsFrom.token.bignum)
+ ) {
+ return true
+ }
+
+ return false
+ }, [fetchingSwapQuote, quote, fromToken, toToken, processedAmountsFrom])
+
+ const warnings = useMemo(() => {
+ const shouldShowMaxSwapAmountWarning =
+ feeCurrenciesWithPositiveBalances.length === 1 &&
+ fromToken &&
+ fromToken.tokenId === feeCurrenciesWithPositiveBalances[0].tokenId &&
+ fromToken.balance.gt(0) &&
+ processedAmountsFrom.token.bignum &&
+ processedAmountsFrom.token.bignum.gte(fromToken.balance)
+
+ // NOTE: If a new condition is added here, make sure to update `allowSwap` below if
+ // the condition should prevent the user from swapping.
+ const checks = {
+ showSwitchedToNetworkWarning: !!switchedToNetworkId,
+ showUnsupportedTokensWarning:
+ !shouldShowSkeletons && fetchSwapQuoteError?.message.includes(NO_QUOTE_ERROR_MESSAGE),
+ showInsufficientBalanceWarning:
+ fromToken &&
+ processedAmountsFrom.token.bignum &&
+ processedAmountsFrom.token.bignum.gt(fromToken.balance),
+ showCrossChainFeeWarning:
+ !shouldShowSkeletons && crossChainFee?.nativeTokenBalanceDeficit.lt(0),
+ showDecreaseSpendForGasWarning:
+ !shouldShowSkeletons &&
+ quote?.preparedTransactions.type === 'need-decrease-spend-amount-for-gas',
+ showNotEnoughBalanceForGasWarning:
+ !shouldShowSkeletons && quote?.preparedTransactions.type === 'not-enough-balance-for-gas',
+ showMaxSwapAmountWarning: shouldShowMaxSwapAmountWarning && !confirmSwapFailed,
+ showNoUsdPriceWarning:
+ !confirmSwapFailed && !shouldShowSkeletons && toToken && !toToken.priceUsd,
+ showPriceImpactWarning:
+ !confirmSwapFailed &&
+ !shouldShowSkeletons &&
+ (quote?.estimatedPriceImpact
+ ? new BigNumber(quote.estimatedPriceImpact).gte(priceImpactWarningThreshold)
+ : false),
+ showMissingPriceImpactWarning: !shouldShowSkeletons && quote && !quote.estimatedPriceImpact,
+ }
+
+ // Only ever show a single warning, according to precedence as above.
+ // Warnings that prevent the user from confirming the swap should
+ // take higher priority over others.
+ return Object.entries(checks).reduce(
+ (acc, [name, status]) => {
+ acc[name] = Object.values(acc).some(Boolean) ? false : !!status
+ return acc
+ },
+ {} as Record
+ )
+ }, [
+ feeCurrenciesWithPositiveBalances,
+ fromToken,
+ toToken,
+ processedAmountsFrom,
+ switchedToNetworkId,
+ shouldShowSkeletons,
+ fetchSwapQuoteError,
+ crossChainFee,
+ quote,
+ confirmSwapFailed,
+ priceImpactWarningThreshold,
+ ])
+
+ const allowSwap = useMemo(
+ () =>
+ !warnings.showDecreaseSpendForGasWarning &&
+ !warnings.showNotEnoughBalanceForGasWarning &&
+ !warnings.showInsufficientBalanceWarning &&
+ !warnings.showCrossChainFeeWarning &&
+ !confirmSwapIsLoading &&
+ !shouldShowSkeletons &&
+ processedAmountsFrom.token.bignum &&
+ processedAmountsFrom.token.bignum.gt(0) &&
+ processedAmountsTo.token.bignum &&
+ processedAmountsTo.token.bignum.gt(0),
+ [
+ processedAmountsFrom.token.bignum,
+ processedAmountsTo.token.bignum,
+ shouldShowSkeletons,
+ confirmSwapIsLoading,
+ warnings.showInsufficientBalanceWarning,
+ warnings.showDecreaseSpendForGasWarning,
+ warnings.showNotEnoughBalanceForGasWarning,
+ warnings.showCrossChainFeeWarning,
+ ]
+ )
+
+ useEffect(
+ function refreshTransactionQuote() {
+ setStartedSwapId(undefined)
+ if (!processedAmountsFrom.token.bignum) {
+ clearQuote()
+ replaceAmountTo('')
+ return
+ }
+
+ const debounceTimeout = setTimeout(() => {
+ const bothTokensPresent = !!(fromToken && toToken)
+ const amountIsTooSmall =
+ !processedAmountsFrom.token.bignum || processedAmountsFrom.token.bignum.lte(0)
+
+ if (!bothTokensPresent || amountIsTooSmall) {
+ return
+ }
+
+ // This variable prevents the quote from needlessly being fetched again.
+ const quoteIsTheSameAsTheLastOne =
+ quote &&
+ quote.toTokenId === toToken.tokenId &&
+ quote.fromTokenId === fromToken.tokenId &&
+ processedAmountsFrom.token.bignum &&
+ quote.swapAmount.eq(processedAmountsFrom.token.bignum)
+
+ if (!quoteIsTheSameAsTheLastOne) {
+ replaceAmountTo('')
+ void refreshQuote(
+ fromToken,
+ toToken,
+ { FROM: processedAmountsFrom.token.bignum, TO: null },
+ Field.FROM
+ )
+ }
+ }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS)
+
+ return () => {
+ clearTimeout(debounceTimeout)
+ }
+ },
+ [
+ processedAmountsFrom.token.bignum?.toString(),
+ fromToken,
+ toToken,
+ quote,
+ refreshQuote,
+ /**
+ * TODO
+ * This useEffect doesn't follow the rules of hooks which can introduce unnecessary bugs.
+ * Functions below should be optimized to not cause unnecessary re-runs. Once that's done -
+ * they should be uncommented.
+ */
+ // clearQuote,
+ // replaceAmountTo,
+ ]
+ )
+
+ useEffect(function trackSwapScreenOpen() {
+ AppAnalytics.track(SwapEvents.swap_screen_open)
+ }, [])
+
+ useEffect(
+ function trackImpactWarningDisplayed() {
+ if (warnings.showPriceImpactWarning || warnings.showMissingPriceImpactWarning) {
+ if (!quote) {
+ return
+ }
+ const fromToken = tokensById[quote.fromTokenId]
+ const toToken = tokensById[quote.toTokenId]
+
+ if (!fromToken || !toToken) {
+ // Should never happen
+ Logger.error(TAG, 'fromToken or toToken not found')
+ return
+ }
+
+ AppAnalytics.track(SwapEvents.swap_price_impact_warning_displayed, {
+ toToken: toToken.address,
+ toTokenId: toToken.tokenId,
+ toTokenNetworkId: toToken.networkId,
+ toTokenIsImported: !!toToken.isManuallyImported,
+ fromToken: fromToken.address,
+ fromTokenId: fromToken.tokenId,
+ fromTokenNetworkId: fromToken?.networkId,
+ fromTokenIsImported: !!fromToken.isManuallyImported,
+ amount: processedAmountsFrom.token.bignum
+ ? processedAmountsFrom.token.bignum.toString()
+ : '',
+ amountType: 'sellAmount',
+ priceImpact: quote.estimatedPriceImpact,
+ provider: quote.provider,
+ })
+ }
+ },
+ [warnings.showPriceImpactWarning || warnings.showMissingPriceImpactWarning]
+ )
+
+ function handleOpenTokenPicker(field: Field) {
+ AppAnalytics.track(SwapEvents.swap_screen_select_token, { fieldType: field })
+ // use requestAnimationFrame so that the bottom sheet open animation is done
+ // after the selectingField value is updated, so that the title of the
+ // bottom sheet (which depends on selectingField) does not change on the
+ // screen
+ requestAnimationFrame(() => {
+ const ref = field === Field.FROM ? tokenBottomSheetFromRef : tokenBottomSheetToRef
+ ref.current?.snapToIndex(0)
+ })
+ }
+
+ function handleConfirmSwap() {
+ if (!quote) {
+ return // this should never happen, because the button must be disabled in that cases
+ }
+
+ const fromToken = tokensById[quote.fromTokenId]
+ const toToken = tokensById[quote.toTokenId]
+
+ if (!fromToken || !toToken) {
+ // Should never happen
+ return
+ }
+
+ const userInput = {
+ toTokenId: toToken.tokenId,
+ fromTokenId: fromToken.tokenId,
+ swapAmount: {
+ [Field.FROM]: processedAmountsFrom.token.bignum?.toString() ?? '',
+ [Field.TO]: processedAmountsTo.token.bignum?.toString() ?? '',
+ },
+ updatedField: Field.FROM,
+ }
+
+ const { estimatedPriceImpact, price, allowanceTarget, appFeePercentageIncludedInPrice } = quote
+
+ const resultType = quote.preparedTransactions.type
+ switch (resultType) {
+ case 'need-decrease-spend-amount-for-gas': // fallthrough on purpose
+ case 'not-enough-balance-for-gas':
+ // This should never actually happen, since the user should not be able
+ // to confirm the swap in this case.
+ break
+ case 'possible':
+ AppAnalytics.track(SwapEvents.swap_review_submit, {
+ toToken: toToken.address,
+ toTokenId: toToken.tokenId,
+ toTokenNetworkId: toToken.networkId,
+ toTokenIsImported: !!toToken.isManuallyImported,
+ fromToken: fromToken.address,
+ fromTokenId: fromToken.tokenId,
+ fromTokenNetworkId: fromToken.networkId,
+ fromTokenIsImported: !!fromToken.isManuallyImported,
+ amount: processedAmountsFrom.token.bignum?.toString() || '',
+ amountType: 'sellAmount',
+ allowanceTarget,
+ estimatedPriceImpact,
+ price,
+ appFeePercentageIncludedInPrice,
+ provider: quote.provider,
+ swapType: quote.swapType,
+ web3Library: 'viem',
+ ...getSwapTxsAnalyticsProperties(
+ quote.preparedTransactions.transactions,
+ fromToken.networkId,
+ tokensById
+ ),
+ })
+
+ const swapId = uuidv4()
+ setStartedSwapId(swapId)
+ dispatch(
+ swapStart({
+ swapId,
+ quote: {
+ preparedTransactions: getSerializablePreparedTransactions(
+ quote.preparedTransactions.transactions
+ ),
+ receivedAt: quote.receivedAt,
+ price: quote.price,
+ appFeePercentageIncludedInPrice,
+ provider: quote.provider,
+ estimatedPriceImpact,
+ allowanceTarget,
+ swapType: quote.swapType,
+ },
+ userInput,
+ areSwapTokensShuffled,
+ })
+ )
+ break
+ default:
+ // To catch any missing cases at compile time
+ const assertNever: never = resultType
+ return assertNever
+ }
+ }
+
+ function handleSwitchTokens() {
+ AppAnalytics.track(SwapEvents.swap_switch_tokens, {
+ fromTokenId: fromToken?.tokenId,
+ toTokenId: toToken?.tokenId,
+ })
+
+ setFromToken(toToken)
+ setToToken(fromToken)
+ replaceAmountTo('')
+ }
+
+ function handleConfirmSelectTokenNoUsdPrice() {
+ if (noUsdPriceToken) {
+ handleConfirmSelectToken({
+ field: Field.TO,
+ selectedToken: noUsdPriceToken.token,
+ tokenPositionInList: noUsdPriceToken.tokenPositionInList,
+ })
+ setNoUsdPriceToken(undefined)
+ }
+ }
+
+ function handleDismissSelectTokenNoUsdPrice() {
+ setNoUsdPriceToken(undefined)
+ }
+
+ const handleConfirmSelectToken = ({
+ field,
+ selectedToken,
+ tokenPositionInList,
+ }: {
+ field: Field
+ selectedToken: TokenBalance
+ tokenPositionInList: number
+ }) => {
+ if (!field) {
+ // Should never happen
+ Logger.error(TAG, 'handleConfirmSelectToken called without field')
+ return
+ }
+
+ let newFromToken = fromToken
+ let newToToken = toToken
+ let newSwitchedToNetwork: typeof switchedToNetworkId | null = null
+
+ switch (true) {
+ // If we're selecting a field that was already selected in the other input then switch inputs
+ case (field === Field.FROM && toToken?.tokenId === selectedToken.tokenId) ||
+ (field === Field.TO && fromToken?.tokenId === selectedToken.tokenId): {
+ newFromToken = toToken
+ newToToken = fromToken
+ break
+ }
+
+ case field === Field.FROM: {
+ newFromToken = selectedToken
+ newSwitchedToNetwork =
+ toToken && toToken.networkId !== newFromToken.networkId && !allowCrossChainSwaps
+ ? { networkId: newFromToken.networkId, field: Field.FROM }
+ : null
+ if (newSwitchedToNetwork) {
+ // reset the toToken if the user is switching networks
+ newToToken = undefined
+ }
+ break
+ }
+
+ case field === Field.TO: {
+ if (!selectedToken.priceUsd && !noUsdPriceToken) {
+ setNoUsdPriceToken({ token: selectedToken, tokenPositionInList })
+ return
+ }
+
+ newToToken = selectedToken
+ newSwitchedToNetwork =
+ fromToken && fromToken.networkId !== newToToken.networkId && !allowCrossChainSwaps
+ ? { networkId: newToToken.networkId, field: Field.TO }
+ : null
+ if (newSwitchedToNetwork) {
+ // reset the fromToken if the user is switching networks
+ newFromToken = undefined
+ }
+ }
+ }
+
+ AppAnalytics.track(SwapEvents.swap_screen_confirm_token, {
+ fieldType: field,
+ tokenSymbol: selectedToken.symbol,
+ tokenId: selectedToken.tokenId,
+ tokenNetworkId: selectedToken.networkId,
+ fromTokenSymbol: newFromToken?.symbol,
+ fromTokenId: newFromToken?.tokenId,
+ fromTokenNetworkId: newFromToken?.networkId,
+ toTokenSymbol: newToToken?.symbol,
+ toTokenId: newToToken?.tokenId,
+ toTokenNetworkId: newToToken?.networkId,
+ switchedNetworkId: !!newSwitchedToNetwork,
+ areSwapTokensShuffled,
+ tokenPositionInList,
+ })
+
+ setFromToken(newFromToken)
+ setToToken(newToToken)
+ setSwitchedToNetworkId(allowCrossChainSwaps ? null : newSwitchedToNetwork)
+ setStartedSwapId(undefined)
+ replaceAmountTo('')
+
+ if (newSwitchedToNetwork) {
+ clearQuote()
+ }
+
+ // use requestAnimationFrame so that the bottom sheet and keyboard dismiss
+ // animation can be synchronised and starts after the state changes above.
+ // without this, the keyboard animation lags behind the state updates while
+ // the bottom sheet does not
+ requestAnimationFrame(() => {
+ const ref = field === Field.FROM ? tokenBottomSheetFromRef : tokenBottomSheetToRef
+ ref.current?.close()
+ })
+ }
+
+ function handleSelectAmountPercentage(percentage: number) {
+ handleSelectPercentageAmount(percentage)
+ setSelectedPercentage(percentage)
+
+ if (!fromToken) {
+ // Should never happen
+ return
+ }
+ AppAnalytics.track(SwapEvents.swap_screen_percentage_selected, {
+ tokenSymbol: fromToken.symbol,
+ tokenId: fromToken.tokenId,
+ tokenNetworkId: fromToken.networkId,
+ percentage,
+ })
+ }
+
+ function handlePressLearnMore() {
+ AppAnalytics.track(SwapEvents.swap_learn_more)
+ navigate(Screens.WebViewScreen, { uri: links.swapLearnMore })
+ }
+
+ function handlePressLearnMoreFees() {
+ AppAnalytics.track(SwapEvents.swap_gas_fees_learn_more)
+ navigate(Screens.WebViewScreen, { uri: links.transactionFeesLearnMore })
+ }
+
+ return (
+
+ }
+ title={t('swapScreen.title')}
+ />
+
+
+
+
+ handleOpenTokenPicker(Field.FROM)}
+ testID="SwapAmountInput"
+ />
+
+
+
+
+
+
+
+ handleOpenTokenPicker(Field.TO)}
+ loading={shouldShowSkeletons}
+ testID="SwapAmountInput"
+ />
+
+
+ {showCrossChainSwapNotification && (
+
+
+
+ {t('swapScreen.crossChainNotification')}
+
+
+ )}
+
+
+
+
+ {warnings.showCrossChainFeeWarning && (
+
+ )}
+ {warnings.showDecreaseSpendForGasWarning && (
+ {
+ if (
+ !quote ||
+ quote.preparedTransactions.type !== 'need-decrease-spend-amount-for-gas'
+ )
+ return
+ handleAmountInputChange(
+ quote.preparedTransactions.decreasedSpendAmount.toString()
+ )
+ }}
+ ctaLabel={t('swapScreen.decreaseSwapAmountForGasWarning.cta')}
+ style={styles.warning}
+ />
+ )}
+ {warnings.showNotEnoughBalanceForGasWarning && (
+
+ )}
+ {warnings.showInsufficientBalanceWarning && (
+
+ )}
+ {warnings.showUnsupportedTokensWarning && (
+
+ )}
+ {warnings.showSwitchedToNetworkWarning && (
+
+ )}
+ {warnings.showMaxSwapAmountWarning && (
+
+ )}
+ {warnings.showPriceImpactWarning && (
+
+ )}
+ {warnings.showNoUsdPriceWarning && (
+
+ )}
+ {warnings.showMissingPriceImpactWarning && (
+
+ )}
+ {confirmSwapFailed && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ handleConfirmSelectToken({ field: Field.FROM, selectedToken: token, tokenPositionInList })
+ }}
+ areSwapTokensShuffled={areSwapTokensShuffled}
+ />
+ {
+ handleConfirmSelectToken({ field: Field.TO, selectedToken: token, tokenPositionInList })
+ }}
+ areSwapTokensShuffled={areSwapTokensShuffled}
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ safeAreaContainer: {
+ flex: 1,
+ },
+ contentContainer: {
+ padding: Spacing.Regular16,
+ flexGrow: 1,
+ display: 'flex',
+ justifyContent: 'space-between',
+ },
+ inputsAndWarningsContainer: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ flexGrow: 1,
+ },
+ inputsContainer: {
+ paddingBottom: Spacing.Thick24,
+ flex: 1,
+ gap: 4,
+ },
+ warningsContainer: {
+ paddingBottom: Spacing.Thick24,
+ flex: 1,
+ display: 'flex',
+ justifyContent: 'flex-end',
+ },
+ disclaimerText: {
+ ...typeScale.labelXXSmall,
+ paddingBottom: Spacing.Smallest8,
+ flexWrap: 'wrap',
+ color: colors.gray3,
+ textAlign: 'center',
+ },
+ disclaimerLink: {
+ ...typeScale.labelXXSmall,
+ color: colors.black,
+ },
+ warning: {
+ marginTop: Spacing.Thick24,
+ },
+ bottomSheetButton: {
+ marginTop: Spacing.Thick24,
+ },
+ switchTokens: {
+ position: 'absolute',
+ top: -20,
+ left: -Spacing.Regular16,
+ zIndex: 1,
+ },
+ switchTokensContainer: {
+ zIndex: 1,
+ alignItems: 'center',
+ },
+ crossChainNotificationWrapper: {
+ alignSelf: 'center',
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingBottom: Spacing.Thick24,
+ },
+ crossChainNotification: {
+ ...typeScale.labelXSmall,
+ paddingLeft: Spacing.Tiny4,
+ color: colors.gray4,
+ },
+})
diff --git a/src/swap/SwapTransactionDetails.tsx b/src/swap/SwapTransactionDetails.tsx
index dfcf2c208de..d83f0595e3e 100644
--- a/src/swap/SwapTransactionDetails.tsx
+++ b/src/swap/SwapTransactionDetails.tsx
@@ -162,7 +162,7 @@ export function SwapTransactionDetails({
const placeholder = '-'
- if (!toToken || !fromToken || !exchangeRatePrice) {
+ if (!toToken || !fromToken || !exchangeRatePrice || fetchingSwapQuote) {
return null
}
diff --git a/src/swap/types.ts b/src/swap/types.ts
index 9d58f31b357..a7d2bc55a79 100644
--- a/src/swap/types.ts
+++ b/src/swap/types.ts
@@ -15,8 +15,8 @@ export interface SwapAmount {
}
export interface ParsedSwapAmount {
- [Field.FROM]: BigNumber
- [Field.TO]: BigNumber
+ [Field.FROM]: BigNumber | null
+ [Field.TO]: BigNumber | null
}
interface SwapUserInput {
diff --git a/src/swap/useSwapQuote.ts b/src/swap/useSwapQuote.ts
index dcd8da25362..ecdf7d67f8f 100644
--- a/src/swap/useSwapQuote.ts
+++ b/src/swap/useSwapQuote.ts
@@ -162,10 +162,14 @@ function useSwapQuote({
networkId,
slippagePercentage,
enableAppFee,
+ onSuccess,
+ onError,
}: {
networkId: NetworkId
slippagePercentage: string
enableAppFee: boolean
+ onSuccess?(result: QuoteResult | null): void
+ onError?(error: Error): void
}) {
const walletAddress = useSelector(walletAddressSelector)
const feeCurrencies = useSelector((state) => feeCurrenciesSelector(state, networkId))
@@ -183,7 +187,7 @@ function useSwapQuote({
return null
}
- if (!swapAmount[updatedField].gt(0)) {
+ if (!swapAmount[updatedField]) {
return null
}
@@ -264,7 +268,9 @@ function useSwapQuote({
{
// Keep last result when refreshing
setLoading: (state) => ({ ...state, loading: true }),
+ onSuccess: (result) => onSuccess?.(result),
onError: (error: Error) => {
+ onError?.(error)
Logger.warn('SwapScreen@useSwapQuote', 'error from approve swap url', error)
},
}