Skip to content

Commit

Permalink
fix(imported-tokens): Improving the swap experience for imported toke…
Browse files Browse the repository at this point in the history
…ns (#4950)
  • Loading branch information
dievazqu authored Feb 23, 2024
1 parent 632b208 commit 88b7ba9
Show file tree
Hide file tree
Showing 12 changed files with 115 additions and 15 deletions.
1 change: 1 addition & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -1746,6 +1746,7 @@
"swapFromTokenSelection": "SWAP FROM",
"swapToTokenSelection": "SWAP TO",
"tokenUsdValueUnknown": "-",
"unsupportedSwapTokens": "The selected tokens aren't supported by our swap providers.",
"switchedToNetworkWarning": {
"title": "You switched to the {{networkName}} network",
"body_swapFrom": "Select a token to swap from on the {{networkName}} network to continue.",
Expand Down
2 changes: 2 additions & 0 deletions src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,7 @@ interface SwapEvent {
toToken: string | null | undefined
toTokenId: string
toTokenNetworkId: string
toTokenIsImported: boolean
/**
* Address of the from token
*
Expand All @@ -1176,6 +1177,7 @@ interface SwapEvent {
fromToken: string | null | undefined
fromTokenId: string
fromTokenNetworkId: string
fromTokenIsImported: boolean
/**
* Starting with v1.74, this amount is always in decimal format
* Before that it was in token smallest unit or decimal format depending on the event.
Expand Down
1 change: 1 addition & 0 deletions src/app/ErrorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,5 @@ export enum ErrorMessages {
INSUFFICIENT_BALANCE_STABLE = 'insufficientBalanceStable',
HOOKS_INVALID_PREVIEW_API_URL = 'hooksPreview.invalidApiUrl',
SHORTCUT_CLAIM_REWARD_FAILED = 'dappShortcuts.claimRewardFailure',
UNSUPPORTED_SWAP_TOKENS = 'swapScreen.unsupportedSwapTokens',
}
6 changes: 3 additions & 3 deletions src/components/TokenIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ export enum IconSize {

const IconSizeToStyle = {
[IconSize.SMALL]: {
tokenImageSize: 20,
networkImageSize: 8,
networkImagePosition: 13,
tokenImageSize: 24,
networkImageSize: 9,
networkImagePosition: 15,
tokenTextSize: 6,
},
[IconSize.MEDIUM]: {
Expand Down
4 changes: 2 additions & 2 deletions src/swap/SwapAmountInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import BigNumber from 'bignumber.js'
import React, { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Image,
Platform,
TextInput as RNTextInput,
StyleProp,
Expand All @@ -14,6 +13,7 @@ import {
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'
import Touchable from 'src/components/Touchable'
import DownArrowIcon from 'src/icons/DownArrowIcon'
import Colors from 'src/styles/colors'
Expand Down Expand Up @@ -139,7 +139,7 @@ const SwapAmountInput = ({
>
{token ? (
<>
<Image source={{ uri: token.imageUrl }} style={styles.tokenImage} />
<TokenIcon token={token} viewStyle={styles.tokenImage} size={IconSize.SMALL} />
<Text style={styles.tokenName}>{token.symbol}</Text>
<DownArrowIcon color={Colors.gray5} />
</>
Expand Down
28 changes: 27 additions & 1 deletion src/swap/SwapScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { StatsigFeatureGates } from 'src/statsig/types'
import SwapScreen from 'src/swap/SwapScreen'
import { swapStart } from 'src/swap/slice'
import { 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'
Expand Down Expand Up @@ -118,6 +119,7 @@ const mockStoreTokenBalances = {
isSwappable: true,
balance: '10',
priceUsd: '1',
imageUrl: 'https://example.com/usdc.png',
},
}

Expand Down Expand Up @@ -594,9 +596,11 @@ describe('SwapScreen', () => {
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',
Expand Down Expand Up @@ -660,9 +664,11 @@ describe('SwapScreen', () => {
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,
Expand Down Expand Up @@ -928,7 +934,7 @@ describe('SwapScreen', () => {
})

it('should display an error banner if api request fails', async () => {
mockFetch.mockReject()
mockFetch.mockReject(new Error('Failed to fetch'))

const { swapFromContainer, getByText, store, swapScreen } = renderScreen({})

Expand All @@ -945,6 +951,24 @@ describe('SwapScreen', () => {
)
})

it('should display an unsupported error banner if quote is not available', async () => {
mockFetch.mockReject(new Error(NO_QUOTE_ERROR_MESSAGE))

const { swapFromContainer, getByText, store, swapScreen } = renderScreen({})

selectSwapTokens('CELO', 'cUSD', swapScreen)
fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.234')

await act(() => {
jest.runOnlyPendingTimers()
})

expect(getByText('swapScreen.confirmSwap')).toBeDisabled()
expect(store.getActions()).toEqual(
expect.arrayContaining([showError(ErrorMessages.UNSUPPORTED_SWAP_TOKENS)])
)
})

it('should be able to start a swap', async () => {
const quoteReceivedTimestamp = 1000
jest.spyOn(Date, 'now').mockReturnValue(quoteReceivedTimestamp) // quote received timestamp
Expand Down Expand Up @@ -1106,9 +1130,11 @@ describe('SwapScreen', () => {
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,
Expand Down
12 changes: 10 additions & 2 deletions src/swap/SwapScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { currentSwapSelector, priceImpactWarningThresholdSelector } from 'src/sw
import { swapStart } from 'src/swap/slice'
import { Field, SwapAmount } from 'src/swap/types'
import useFilterChips from 'src/swap/useFilterChips'
import useSwapQuote, { QuoteResult } from 'src/swap/useSwapQuote'
import useSwapQuote, { NO_QUOTE_ERROR_MESSAGE, QuoteResult } from 'src/swap/useSwapQuote'
import { useSwappableTokens, useTokenInfo, useTokensWithTokenBalance } from 'src/tokens/hooks'
import { feeCurrenciesWithPositiveBalancesSelector, tokensByIdSelector } from 'src/tokens/selectors'
import { TokenBalance } from 'src/tokens/slice'
Expand Down Expand Up @@ -334,7 +334,11 @@ export function SwapScreen({ route }: Props) {

useEffect(() => {
if (fetchSwapQuoteError) {
dispatch(showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED))
if (fetchSwapQuoteError.message.includes(NO_QUOTE_ERROR_MESSAGE)) {
dispatch(showError(ErrorMessages.UNSUPPORTED_SWAP_TOKENS))
} else {
dispatch(showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED))
}
}
}, [fetchSwapQuoteError])

Expand Down Expand Up @@ -415,9 +419,11 @@ export function SwapScreen({ route }: Props) {
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: inputSwapAmount[updatedField],
amountType: swapAmountParam,
allowanceTarget,
Expand Down Expand Up @@ -639,9 +645,11 @@ export function SwapScreen({ route }: Props) {
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: parsedSwapAmount[updatedField].toString(),
amountType: updatedField === Field.FROM ? 'sellAmount' : 'buyAmount',
priceImpact: quote.estimatedPriceImpact,
Expand Down
57 changes: 53 additions & 4 deletions src/swap/saga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
mockCrealAddress,
mockCrealTokenId,
mockEthTokenId,
mockTestTokenAddress,
mockTestTokenTokenId,
mockTokenBalances,
mockUSDCAddress,
mockUSDCTokenId,
Expand Down Expand Up @@ -63,15 +65,15 @@ jest.mock('src/transactions/types', () => {
const mockAllowanceTarget = '0xdef1c0ded9bec7f1a1670819833240f027b25eff'
const mockQuoteReceivedTimestamp = 1_000_000_000_000

const mockSwapWithFeeCurrency = (feeCurrency?: Address): PayloadAction<SwapInfo> => {
const mockSwapFromParams = (toTokenId: string, feeCurrency?: Address): PayloadAction<SwapInfo> => {
return {
type: swapStart.type,
payload: {
swapId: 'test-swap-id',
userInput: {
updatedField: Field.TO,
fromTokenId: mockCeurTokenId,
toTokenId: mockCeloTokenId,
toTokenId,
swapAmount: {
[Field.FROM]: '100',
[Field.TO]: '200',
Expand Down Expand Up @@ -111,7 +113,8 @@ const mockSwapWithFeeCurrency = (feeCurrency?: Address): PayloadAction<SwapInfo>
}
}

const mockSwap = mockSwapWithFeeCurrency()
const mockSwap = mockSwapFromParams(mockCeloTokenId)
const mockSwapToImportedToken = mockSwapFromParams(mockTestTokenTokenId)

const mockSwapEthereum: PayloadAction<SwapInfo> = {
type: swapStart.type,
Expand Down Expand Up @@ -232,6 +235,18 @@ const store = createMockStore({
priceUsd: '0.5',
balance: '10',
},
[mockTestTokenTokenId]: {
...mockTokenBalances[mockTestTokenTokenId],
priceUsd: '0.1',
address: mockTestTokenAddress,
tokenId: mockTestTokenTokenId,
networkId: NetworkId['celo-alfajores'],
symbol: 'TT',
name: 'Imported Token',
decimals: 18,
balance: '5',
isManuallyImported: true,
},
},
},
})
Expand Down Expand Up @@ -326,7 +341,7 @@ describe(swapSubmitSaga, () => {
feeCurrencyAddress: mockCrealAddress,
feeCurrencyId: mockCrealTokenId,
feeCurrencySymbol: 'cREAL',
swapPrepared: mockSwapWithFeeCurrency(mockCrealAddress as Address),
swapPrepared: mockSwapFromParams(mockCeloTokenId, mockCrealAddress as Address),
expectedFees: [
{
type: 'SECURITY_FEE',
Expand Down Expand Up @@ -446,9 +461,11 @@ describe(swapSubmitSaga, () => {
toToken: toTokenAddress,
toTokenId: toTokenId,
toTokenNetworkId: networkId,
toTokenIsImported: false,
fromToken: fromTokenAddress,
fromTokenId: fromTokenId,
fromTokenNetworkId: networkId,
fromTokenIsImported: false,
amount: swapPrepared.payload.userInput.swapAmount[Field.TO],
amountType: 'buyAmount',
price: '1',
Expand Down Expand Up @@ -626,6 +643,36 @@ describe(swapSubmitSaga, () => {
.run()
})

it('should track correctly the imported tokens', async () => {
jest
.spyOn(Date, 'now')
.mockReturnValueOnce(mockQuoteReceivedTimestamp + 2_500) // swap submitted timestamp
.mockReturnValue(mockQuoteReceivedTimestamp + 10_000) // before send swap timestamp

await expectSaga(swapSubmitSaga, mockSwapToImportedToken)
.withState(store.getState())
.provide(createDefaultProviders(Network.Celo))
.put(
swapSuccess({
swapId: 'test-swap-id',
fromTokenId: mockCeurTokenId,
toTokenId: mockTestTokenTokenId,
})
)
.run()

expect(mockViemWallet.signTransaction).toHaveBeenCalledTimes(2)
expect(mockViemWallet.sendRawTransaction).toHaveBeenCalledTimes(2)
expect(loggerErrorSpy).not.toHaveBeenCalled()
expect(navigate).toHaveBeenCalledWith(Screens.WalletHome)

expect(ValoraAnalytics.track).toHaveBeenCalledTimes(1)
expect(ValoraAnalytics.track).toHaveBeenLastCalledWith(
SwapEvents.swap_execute_success,
expect.objectContaining({ fromTokenIsImported: false, toTokenIsImported: true })
)
})

it('should set swap state correctly on error', async () => {
jest
.spyOn(Date, 'now')
Expand All @@ -647,9 +694,11 @@ describe(swapSubmitSaga, () => {
toToken: mockCeloAddress,
toTokenId: mockCeloTokenId,
toTokenNetworkId: NetworkId['celo-alfajores'],
toTokenIsImported: false,
fromToken: mockCeurAddress,
fromTokenId: mockCeurTokenId,
fromTokenNetworkId: NetworkId['celo-alfajores'],
fromTokenIsImported: false,
amount: mockSwap.payload.userInput.swapAmount[Field.TO],
amountType: 'buyAmount',
price: '1',
Expand Down
2 changes: 2 additions & 0 deletions src/swap/saga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,11 @@ export function* swapSubmitSaga(action: PayloadAction<SwapInfo>) {
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,
amountType,
price,
Expand Down
9 changes: 8 additions & 1 deletion src/swap/useSwapQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { Address, Hex, encodeFunctionData, zeroAddress } from 'viem'
// varying gas fees of different swap providers (or even the same swap)
const DECREASED_SWAP_AMOUNT_GAS_FEE_MULTIPLIER = 1.2

export const NO_QUOTE_ERROR_MESSAGE = 'No quote available'

export interface QuoteResult {
toTokenId: string
fromTokenId: string
Expand Down Expand Up @@ -182,6 +184,11 @@ function useSwapQuote(networkId: NetworkId, slippagePercentage: string) {
}

const quote: FetchQuoteResponse = await response.json()

if (!quote.unvalidatedSwapTransaction) {
throw new Error(NO_QUOTE_ERROR_MESSAGE)
}

const swapPrice = quote.unvalidatedSwapTransaction.price
const price =
updatedField === Field.FROM
Expand Down Expand Up @@ -225,7 +232,7 @@ function useSwapQuote(networkId: NetworkId, slippagePercentage: string) {
return {
quote: refreshQuote.result ?? null,
refreshQuote: refreshQuote.execute,
fetchSwapQuoteError: refreshQuote.status === 'error',
fetchSwapQuoteError: refreshQuote.error,
fetchingSwapQuote: refreshQuote.loading,
clearQuote,
}
Expand Down
7 changes: 5 additions & 2 deletions src/tokens/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,14 +318,16 @@ export const swappableFromTokensByNetworkIdSelector = createSelector(
(state: RootState, networkIds: NetworkId[]) => tokensListSelector(state, networkIds),
(tokens) => {
const appVersion = deviceInfoModule.getVersion()

return (
tokens
.filter(
(tokenInfo) =>
tokenInfo.isSwappable ||
tokenInfo.isManuallyImported ||
tokenInfo.balance.gt(TOKEN_MIN_AMOUNT) ||
(tokenInfo.minimumAppVersionToSwap &&
!isVersionBelowMinimum(appVersion, tokenInfo.minimumAppVersionToSwap)) ||
tokenInfo.balance.gt(TOKEN_MIN_AMOUNT)
!isVersionBelowMinimum(appVersion, tokenInfo.minimumAppVersionToSwap))
)
// sort by balance USD (DESC) then name (ASC), tokens without a priceUsd
// are pushed last, sorted by name (ASC)
Expand Down Expand Up @@ -366,6 +368,7 @@ export const swappableToTokensByNetworkIdSelector = createSelector(
return tokens.filter(
(tokenInfo) =>
tokenInfo.isSwappable ||
tokenInfo.isManuallyImported ||
(tokenInfo.minimumAppVersionToSwap &&
!isVersionBelowMinimum(appVersion, tokenInfo.minimumAppVersionToSwap))
)
Expand Down
Loading

0 comments on commit 88b7ba9

Please sign in to comment.