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>", "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 && ( + + )} + + + + + + + + + +