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'
import { v4 as uuidv4 } from 'uuid'
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),
})),
}))
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}))
const mockedUuidv4 = uuidv4 as jest.Mock
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 mockSwapId = 'test-swap-id'
mockedUuidv4.mockReturnValue(mockSwapId)
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',
swapId: mockSwapId,
})
})
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()
})
})
})
})