Skip to content

Commit

Permalink
feat(cico): make room for gas fees on withdraw (#2601)
Browse files Browse the repository at this point in the history
### Description

I extracted the fee calculation logic from SendAmount/index.tsx, added unit tests, and utilized it in the CICO flow very similar to how its utilized in the Send flow when a user tries to send all of their crypto away.

### Tested

Unit tests and checked the CICO / SEND flows

### How others should test

does not need to be tested

### Fixes

https://app.zenhub.com/workspaces/acquisition-squad-sprint-board-6010683afabec1001a090887/issues/valora-inc/wallet/2555
  • Loading branch information
jh2oman committed Jun 17, 2022
1 parent 19b7bc8 commit 0e62180
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 72 deletions.
195 changes: 195 additions & 0 deletions src/fees/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { render } from '@testing-library/react-native'
import React from 'react'
import { Text, View } from 'react-native'
import { Provider } from 'react-redux'
import { useMaxSendAmount } from 'src/fees/hooks'
import { estimateFee, FeeType } from 'src/fees/reducer'
import { RootState } from 'src/redux/reducers'
import { ONE_HOUR_IN_MILLIS } from 'src/utils/time'
import { createMockStore, getElementText, RecursivePartial } from 'test/utils'
import {
emptyFees,
mockCeloAddress,
mockCeurAddress,
mockCusdAddress,
mockFeeInfo,
} from 'test/values'

interface ComponentProps {
feeType: FeeType.INVITE | FeeType.SEND
tokenAddress: string
shouldRefresh: boolean
}
function TestComponent({ feeType, tokenAddress, shouldRefresh }: ComponentProps) {
const max = useMaxSendAmount(tokenAddress, feeType, shouldRefresh)
return (
<View>
<Text testID="maxSendAmount">{max.toString()}</Text>
</View>
)
}

const mockFeeEstimates = (error: boolean = false, lastUpdated: number = Date.now()) => ({
...emptyFees,
[FeeType.SEND]: {
usdFee: '0.02',
lastUpdated,
loading: false,
error,
feeInfo: mockFeeInfo,
},
[FeeType.INVITE]: {
usdFee: '0.04',
lastUpdated,
loading: false,
error,
feeInfo: mockFeeInfo,
},
})

describe('useMaxSendAmount', () => {
beforeEach(() => {
jest.clearAllMocks()
})

function renderComponent(
storeOverrides: RecursivePartial<RootState> = {},
props: ComponentProps
) {
const store = createMockStore({
tokens: {
tokenBalances: {
[mockCusdAddress]: {
address: mockCusdAddress,
symbol: 'cUSD',
balance: '200',
usdPrice: '1',
isCoreToken: true,
priceFetchedAt: Date.now(),
},
[mockCeurAddress]: {
address: mockCeurAddress,
symbol: 'cEUR',
balance: '100',
usdPrice: '1.2',
isCoreToken: true,
priceFetchedAt: Date.now(),
},
[mockCeloAddress]: {
address: mockCeloAddress,
symbol: 'CELO',
balance: '200',
usdPrice: '5',
isCoreToken: true,
priceFetchedAt: Date.now(),
},
},
},
fees: {
estimates: {
[mockCusdAddress]: mockFeeEstimates(),
[mockCeurAddress]: mockFeeEstimates(),
[mockCeloAddress]: mockFeeEstimates(),
},
},
...storeOverrides,
})
store.dispatch = jest.fn()
const tree = render(
<Provider store={store}>
<TestComponent {...props} />
</Provider>
)

return {
store,
...tree,
}
}

it('returns balance when feeCurrency is not the specified token', () => {
const { getByTestId } = renderComponent(
{},
{ feeType: FeeType.SEND, tokenAddress: mockCusdAddress, shouldRefresh: true }
)
expect(getElementText(getByTestId('maxSendAmount'))).toBe('200')
})
it('returns a balance minus fee estimate when the feeCurrency matches the specified token', () => {
const { getByTestId } = renderComponent(
{},
{ feeType: FeeType.SEND, tokenAddress: mockCeloAddress, shouldRefresh: true }
)
expect(getElementText(getByTestId('maxSendAmount'))).toBe('199.996')
})
it('calls dispatch(estimateFee) when there is a feeEstimate error for the token', () => {
const { getByTestId, store } = renderComponent(
{
fees: {
estimates: {
[mockCusdAddress]: mockFeeEstimates(true),
[mockCeurAddress]: mockFeeEstimates(true),
[mockCeloAddress]: mockFeeEstimates(true),
},
},
},
{ feeType: FeeType.SEND, tokenAddress: mockCusdAddress, shouldRefresh: true }
)
expect(store.dispatch).toHaveBeenCalledWith(
estimateFee({ feeType: FeeType.SEND, tokenAddress: mockCusdAddress })
)
expect(getElementText(getByTestId('maxSendAmount'))).toBe('200')
})
it('calls dispatch(estimateFee) when the feeEstimate is more than an hour old', () => {
const twoHoursAgo = Date.now() - ONE_HOUR_IN_MILLIS * 2
const { getByTestId, store } = renderComponent(
{
fees: {
estimates: {
[mockCusdAddress]: mockFeeEstimates(false, twoHoursAgo),
[mockCeurAddress]: mockFeeEstimates(false, twoHoursAgo),
[mockCeloAddress]: mockFeeEstimates(false, twoHoursAgo),
},
},
},
{ feeType: FeeType.SEND, tokenAddress: mockCusdAddress, shouldRefresh: true }
)
expect(store.dispatch).toHaveBeenCalledWith(
estimateFee({ feeType: FeeType.SEND, tokenAddress: mockCusdAddress })
)
expect(getElementText(getByTestId('maxSendAmount'))).toBe('200')
})
it('does not call dispatch(estimateFee) when the feeEstimate is less than an hour old', () => {
const halfHourAgo = Date.now() - ONE_HOUR_IN_MILLIS / 2
const { getByTestId, store } = renderComponent(
{
fees: {
estimates: {
[mockCusdAddress]: mockFeeEstimates(false, halfHourAgo),
[mockCeurAddress]: mockFeeEstimates(false, halfHourAgo),
[mockCeloAddress]: mockFeeEstimates(false, halfHourAgo),
},
},
},
{ feeType: FeeType.SEND, tokenAddress: mockCeloAddress, shouldRefresh: true }
)
expect(store.dispatch).not.toHaveBeenCalled()
expect(getElementText(getByTestId('maxSendAmount'))).toBe('199.996')
})
it('does not call dispatch(estimateFee) when the shouldRefresh is false', () => {
const twoHoursAgo = Date.now() - ONE_HOUR_IN_MILLIS * 2
const { getByTestId, store } = renderComponent(
{
fees: {
estimates: {
[mockCusdAddress]: mockFeeEstimates(false, twoHoursAgo),
[mockCeurAddress]: mockFeeEstimates(false, twoHoursAgo),
[mockCeloAddress]: mockFeeEstimates(false, twoHoursAgo),
},
},
},
{ feeType: FeeType.SEND, tokenAddress: mockCeloAddress, shouldRefresh: false }
)
expect(store.dispatch).not.toHaveBeenCalled()
expect(getElementText(getByTestId('maxSendAmount'))).toBe('199.996')
})
})
54 changes: 53 additions & 1 deletion src/fees/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import BigNumber from 'bignumber.js'
import { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { estimateFee, FeeType } from 'src/fees/reducer'
import { fetchFeeCurrency } from 'src/fees/saga'
import { feeEstimatesSelector } from 'src/fees/selectors'
import useSelector from 'src/redux/useSelector'
import { tokensByCurrencySelector, tokensByUsdBalanceSelector } from 'src/tokens/selectors'
import { useTokenInfo, useUsdToTokenAmount } from 'src/tokens/hooks'
import {
celoAddressSelector,
tokensByCurrencySelector,
tokensByUsdBalanceSelector,
} from 'src/tokens/selectors'
import { Fee, FeeType as TransactionFeeType } from 'src/transactions/types'
import { Currency } from 'src/utils/currencies'
import { ONE_HOUR_IN_MILLIS } from 'src/utils/time'

export function useFeeCurrency(): string | undefined {
const tokens = useSelector(tokensByUsdBalanceSelector)
Expand Down Expand Up @@ -32,3 +42,45 @@ export function usePaidFees(fees: Fee[]) {
totalFee,
}
}

// Returns the maximum amount a user can send, taking into acount gas fees required for the transaction
// also optionally fetches new fee estimations if the current ones are missing or out of date
export function useMaxSendAmount(
tokenAddress: string,
feeType: FeeType.SEND | FeeType.INVITE,
shouldRefresh: boolean = true
) {
const dispatch = useDispatch()
const { balance } = useTokenInfo(tokenAddress)!
const feeEstimates = useSelector(feeEstimatesSelector)

// Optionally Keep Fees Up to Date
useEffect(() => {
if (!shouldRefresh) return
const feeEstimate = feeEstimates[tokenAddress]?.[feeType]
if (
!feeEstimate ||
feeEstimate.error ||
feeEstimate.lastUpdated < Date.now() - ONE_HOUR_IN_MILLIS
) {
dispatch(estimateFee({ feeType, tokenAddress }))
}
}, [tokenAddress, shouldRefresh])

const celoAddress = useSelector(celoAddressSelector)

// useFeeCurrency chooses which crypto will be used to pay gas fees. It looks at the valid fee currencies (cUSD, cEUR, CELO)
// in order of highest balance and selects the first one that has more than a minimum threshhold of balance
// if CELO is selected then it actually returns undefined
const feeTokenAddress = useFeeCurrency() ?? celoAddress

const usdFeeEstimate = feeEstimates[tokenAddress]?.[feeType]?.usdFee
const feeEstimate =
useUsdToTokenAmount(new BigNumber(usdFeeEstimate ?? 0), tokenAddress) ?? new BigNumber(0)

// For example, if you are sending cUSD but you have more CELO this will be true
if (tokenAddress !== feeTokenAddress) {
return balance
}
return balance.minus(feeEstimate)
}
24 changes: 24 additions & 0 deletions src/fiatExchanges/FiatExchangeAmount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,13 @@ import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { Currency } from 'src/utils/currencies'
import { createMockStore, getElementText, getMockStackScreenProps } from 'test/utils'
import { mockMaxSendAmount } from 'test/values'
import { CICOFlow } from './utils'

jest.mock('src/fees/hooks', () => ({
useMaxSendAmount: () => mockMaxSendAmount,
}))

expect.extend({ toBeDisabled })

const usdExchangeRates = {
Expand Down Expand Up @@ -422,6 +427,25 @@ describe('FiatExchangeAmount cashOut', () => {
)
})

it('shows an error banner if the user balance minus estimated transaction fee is less than the requested cash-out amount', () => {
const tree = render(
<Provider store={storeWithUSD}>
<FiatExchangeAmount {...mockScreenProps} />
</Provider>
)

fireEvent.changeText(tree.getByTestId('FiatExchangeInput'), '999.99999')
fireEvent.press(tree.getByTestId('FiatExchangeNextButton'))
expect(storeWithUSD.getActions()).toEqual(
expect.arrayContaining([
showError(ErrorMessages.CASH_OUT_LIMIT_EXCEEDED, undefined, {
balance: '1000.00',
currency: 'cUSD',
}),
])
)
})

it('navigates to the SelectProvider if the user balance is greater than the requested cash-out amount', () => {
const tree = render(
<Provider store={storeWithUSD}>
Expand Down
11 changes: 6 additions & 5 deletions src/fiatExchanges/FiatExchangeAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
DOLLAR_ADD_FUNDS_MAX_AMOUNT,
} from 'src/config'
import { fetchExchangeRate } from 'src/exchange/actions'
import { useMaxSendAmount } from 'src/fees/hooks'
import { FeeType } from 'src/fees/reducer'
import i18n from 'src/i18n'
import { LocalCurrencyCode, LocalCurrencySymbol } from 'src/localCurrency/consts'
import {
Expand All @@ -41,7 +43,6 @@ import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
import { StackParamList } from 'src/navigator/types'
import DisconnectBanner from 'src/shared/DisconnectBanner'
import { balancesSelector } from 'src/stableToken/selectors'
import colors from 'src/styles/colors'
import fontStyles from 'src/styles/fonts'
import variables from 'src/styles/variables'
Expand Down Expand Up @@ -81,7 +82,6 @@ function FiatExchangeAmount({ route }: Props) {
const inputConvertedToLocalCurrency =
useCurrencyToLocalAmount(parsedInputAmount, currency) || new BigNumber(0)
const localCurrencyCode = useLocalCurrencyCode()
const balances = useSelector(balancesSelector)
const dailyLimitCusd = useSelector(cUsdDailyLimitSelector)
const exchangeRates = useSelector(localCurrencyExchangeRatesSelector)

Expand All @@ -93,7 +93,8 @@ function FiatExchangeAmount({ route }: Props) {
const inputCryptoAmount = inputIsCrypto ? parsedInputAmount : inputConvertedToCrypto
const inputLocalCurrencyAmount = inputIsCrypto ? inputConvertedToLocalCurrency : parsedInputAmount

const balanceCryptoAmount = balances[currency] || new BigNumber(0)
const { address } = useTokenInfoBySymbol(cryptoSymbol)!
const maxWithdrawAmount = useMaxSendAmount(address, FeeType.SEND)

const inputSymbol = inputIsCrypto ? '' : localCurrencySymbol

Expand Down Expand Up @@ -183,10 +184,10 @@ function FiatExchangeAmount({ route }: Props) {
setShowingDailyLimitDialog(true)
return
}
} else if (balanceCryptoAmount.isLessThan(inputCryptoAmount)) {
} else if (maxWithdrawAmount.isLessThan(inputCryptoAmount)) {
dispatch(
showError(ErrorMessages.CASH_OUT_LIMIT_EXCEEDED, ALERT_BANNER_DURATION, {
balance: balanceCryptoAmount.toFixed(2),
balance: maxWithdrawAmount.toFixed(2),
currency: cryptoSymbol,
})
)
Expand Down
Loading

0 comments on commit 0e62180

Please sign in to comment.