Skip to content

Commit

Permalink
feat(earn): support partial withdrawals (#6128)
Browse files Browse the repository at this point in the history
### Description

Adds the ability to support partial withdrawals from Earn pools and adds
the feature gate `allow_earn_partial_withdrawal` to enable this feature.

| Example Deposit | Example Withdrawal |
| ----- | ----- |
|
![](https://github.com/user-attachments/assets/7dd5ca59-fb0c-4bae-b273-7fd65727c643)
|
![](https://github.com/user-attachments/assets/b4054b9a-7db3-445f-8aea-e4e555e88268)
|

### Test plan

- [x] Tested locally on iOS
- [x] Tested locally on Android
- [x] Unit Tests Updated

### Related issues

- ACT-1386

### Backwards compatibility

Yes

### Network scalability

Yes

---------

Co-authored-by: Finnian Jacobson-Schulte <[email protected]>
Co-authored-by: Jacob Waterman <[email protected]>
Co-authored-by: Satish Ravi <[email protected]>
  • Loading branch information
4 people authored Oct 18, 2024
1 parent 89e51c4 commit b1c5b2c
Show file tree
Hide file tree
Showing 13 changed files with 589 additions and 112 deletions.
3 changes: 3 additions & 0 deletions locales/base/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -2554,8 +2554,10 @@
},
"enterAmount": {
"title": "How much would you like to deposit?",
"titleWithdraw": "How much would you like to withdraw?",
"deposit": "Deposit",
"fees": "Fees",
"available": "Available",
"swap": "Swap",
"earnUpToLabel": "You could earn up to:",
"rateLabel": "Rate (est.)",
Expand All @@ -2576,6 +2578,7 @@
"estNetworkFee": "Est. Network Fee",
"maxNetworkFee": "Max Network Fee",
"networkFeeDescription": "The network fee is required by the network to process the deposit transaction.",
"networkFeeDescriptionWithdrawal": "The network fee is required by the network to process the withdrawal transaction.",
"networkSwapFeeDescription": "The network fee is required by the network to process the deposit transactions. The {{appName}} fee of {{appFeePercentage}}% is charged for your use of our product.",
"appSwapFee": "{{appName}} Fee"
},
Expand Down
14 changes: 11 additions & 3 deletions src/analytics/Properties.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import { ErrorMessages } from 'src/app/ErrorMessages'
import { AddAssetsActionType } from 'src/components/AddAssetsBottomSheet'
import { TokenPickerOrigin } from 'src/components/TokenBottomSheet'
import { DappSection } from 'src/dapps/types'
import { BeforeDepositActionName, EarnDepositMode, SerializableRewardsInfo } from 'src/earn/types'
import { BeforeDepositActionName, EarnEnterMode, SerializableRewardsInfo } from 'src/earn/types'
import { ProviderSelectionAnalyticsData } from 'src/fiatExchanges/types'
import { CICOFlow, FiatExchangeFlow, PaymentMethod } from 'src/fiatExchanges/utils'
import { HomeActionName, NotificationBannerCTATypes, NotificationType } from 'src/home/types'
Expand Down Expand Up @@ -590,6 +590,7 @@ interface SendEventsProperties {
tokenId: string
tokenAddress: string | null
networkId: NetworkId | null
mode?: EarnEnterMode
}
[SendEvents.swap_input_pressed]: {
swapToLocalAmount: boolean
Expand Down Expand Up @@ -1553,7 +1554,7 @@ export interface EarnCommonProperties {

interface EarnDepositProperties extends EarnCommonProperties {
depositTokenAmount: string
mode: EarnDepositMode
mode: EarnEnterMode
// the below are mainly for swap-deposit. For deposit, this would just be
// same as the depositTokenAmount and depositTokenId
fromTokenAmount: string
Expand Down Expand Up @@ -1598,7 +1599,14 @@ interface EarnEventsProperties {
[EarnEvents.earn_enter_amount_continue_press]: {
amountInUsd: string
amountEnteredIn: AmountEnteredIn
} & EarnDepositProperties
mode: EarnEnterMode
// For deposits these will be the same as the depositTokenId and depositTokenAmount
// For swaps these will be the swapFromTokenId and swapFromTokenAmount
// For withdrawals this will be in units of the depositToken
fromTokenAmount: string
fromTokenId: string
depositTokenAmount?: string
} & EarnCommonProperties
[EarnEvents.earn_deposit_add_gas_press]: EarnCommonProperties & { gasTokenId: string }
[EarnEvents.earn_feed_item_select]: {
origin: 'EarnDeposit' | 'EarnWithdraw' | 'EarnClaimReward' | 'EarnSwapDeposit'
Expand Down
4 changes: 2 additions & 2 deletions src/earn/EarnDepositBottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { LabelWithInfo } from 'src/components/LabelWithInfo'
import TokenDisplay from 'src/components/TokenDisplay'
import { depositStatusSelector } from 'src/earn/selectors'
import { depositStart } from 'src/earn/slice'
import { EarnDepositMode } from 'src/earn/types'
import { EarnEnterMode } from 'src/earn/types'
import {
getSwapToAmountInDecimals,
getTotalYieldRate,
Expand Down Expand Up @@ -50,7 +50,7 @@ export default function EarnDepositBottomSheet({
inputTokenId: string
inputAmount: BigNumber
pool: EarnPosition
mode: EarnDepositMode
mode: EarnEnterMode
swapTransaction?: SwapTransaction
}) {
const { t } = useTranslation()
Expand Down
175 changes: 164 additions & 11 deletions src/earn/EarnEnterAmount.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Provider } from 'react-redux'
import AppAnalytics from 'src/analytics/AppAnalytics'
import { EarnEvents } from 'src/analytics/Events'
import EarnEnterAmount from 'src/earn/EarnEnterAmount'
import { usePrepareDepositTransactions } from 'src/earn/prepareTransactions'
import { usePrepareTransactions } from 'src/earn/prepareTransactions'
import { CICOFlow } from 'src/fiatExchanges/utils'
import { navigate } from 'src/navigator/NavigationService'
import { Screens } from 'src/navigator/Screens'
Expand All @@ -21,6 +21,7 @@ import networkConfig from 'src/web3/networkConfig'
import MockedNavigator from 'test/MockedNavigator'
import { createMockStore } from 'test/utils'
import {
mockAaveArbUsdcTokenId,
mockAccount,
mockArbArbTokenId,
mockArbEthTokenId,
Expand Down Expand Up @@ -111,7 +112,8 @@ const mockSwapTransaction: SwapTransaction = {
const store = createMockStore({
tokens: {
tokenBalances: {
[mockArbUsdcTokenId]: {
...mockTokenBalances,
mockArbUsdcTokenId: {
...mockTokenBalances[mockArbUsdcTokenId],
balance: '10',
},
Expand All @@ -124,6 +126,10 @@ const store = createMockStore({
minimumAppVersionToSwap: '1.0.0',
balance: '1',
},
mockAaveArbUsdcTokenId: {
...mockTokenBalances[mockAaveArbUsdcTokenId],
balance: '10',
},
},
},
})
Expand All @@ -132,6 +138,12 @@ const params = {
pool: mockEarnPositions[0],
}

const mockPoolWithHighPricePerShare = {
...mockEarnPositions[0],
pricePerShare: ['2'],
balance: '10',
}

describe('EarnEnterAmount', () => {
const refreshPreparedTransactionsSpy = jest.fn()
beforeEach(() => {
Expand All @@ -140,7 +152,7 @@ describe('EarnEnterAmount', () => {
.mocked(getNumberFormatSettings)
.mockReturnValue({ decimalSeparator: '.', groupingSeparator: ',' })
store.clearActions()
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: undefined,
refreshPreparedTransactions: refreshPreparedTransactionsSpy,
clearPreparedTransactions: jest.fn(),
Expand Down Expand Up @@ -187,11 +199,12 @@ describe('EarnEnterAmount', () => {
hooksApiUrl: networkConfig.hooksApiUrl,
feeCurrencies: mockFeeCurrencies,
shortcutId: 'deposit',
useMax: false,
})
})

it('should show tx details and handle navigating to the deposit bottom sheet', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: undefined,
Expand Down Expand Up @@ -262,6 +275,7 @@ describe('EarnEnterAmount', () => {
const store = createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockArbUsdcTokenId]: {
...mockTokenBalances[mockArbUsdcTokenId],
balance: '10',
Expand Down Expand Up @@ -314,11 +328,12 @@ describe('EarnEnterAmount', () => {
hooksApiUrl: networkConfig.hooksApiUrl,
feeCurrencies: mockFeeCurrencies,
shortcutId: 'swap-deposit',
useMax: false,
})
})

it('should show tx details and handle navigating to the deposit bottom sheet', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: mockSwapTransaction,
Expand Down Expand Up @@ -372,9 +387,146 @@ describe('EarnEnterAmount', () => {
})
})

describe('withdraw', () => {
const withdrawParams = { ...params, mode: 'withdraw' }
it('should show the deposit token and a disabled token dropdown', async () => {
const { getByTestId, queryByTestId } = render(
<Provider store={store}>
<MockedNavigator component={EarnEnterAmount} params={withdrawParams} />
</Provider>
)

expect(getByTestId('EarnEnterAmount/TokenSelect')).toBeTruthy()
expect(getByTestId('EarnEnterAmount/TokenSelect')).toHaveTextContent('USDC')
expect(getByTestId('EarnEnterAmount/TokenSelect')).toBeDisabled()
expect(queryByTestId('downArrowIcon')).toBeFalsy()
})

it('should prepare transactions with the expected inputs', async () => {
const { getByTestId } = render(
<Provider store={store}>
<MockedNavigator
component={EarnEnterAmount}
params={{ pool: mockPoolWithHighPricePerShare, mode: 'withdraw' }}
/>
</Provider>
)

fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '.25')

await waitFor(() => expect(refreshPreparedTransactionsSpy).toHaveBeenCalledTimes(1))
expect(refreshPreparedTransactionsSpy).toHaveBeenCalledWith({
amount: '0.125',
token: {
...mockTokenBalances[mockAaveArbUsdcTokenId],
priceUsd: new BigNumber(1),
lastKnownPriceUsd: new BigNumber(1),
balance: new BigNumber(10),
},
walletAddress: mockAccount.toLowerCase(),
pool: mockPoolWithHighPricePerShare,
hooksApiUrl: networkConfig.hooksApiUrl,
feeCurrencies: mockFeeCurrencies,
shortcutId: 'withdraw',
useMax: false,
})
})

it('should show tx details for withdrawal', async () => {
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: undefined,
},
refreshPreparedTransactions: jest.fn(),
clearPreparedTransactions: jest.fn(),
prepareTransactionError: undefined,
isPreparingTransactions: false,
})

const { getByTestId, getByText } = render(
<Provider store={store}>
<MockedNavigator component={EarnEnterAmount} params={withdrawParams} />
</Provider>
)

fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '8')

await waitFor(() => expect(getByText('earnFlow.enterAmount.continue')).not.toBeDisabled())

expect(getByTestId('EarnEnterAmount/Withdraw/Crypto')).toBeTruthy()
expect(getByTestId('EarnEnterAmount/Withdraw/Crypto')).toHaveTextContent('11.00 USDC')

expect(getByTestId('EarnEnterAmount/Withdraw/Fiat')).toBeTruthy()
expect(getByTestId('EarnEnterAmount/Withdraw/Fiat')).toBeTruthy()
expect(getByTestId('EarnEnterAmount/Withdraw/Fiat')).toHaveTextContent('₱14.63')

expect(getByTestId('EarnEnterAmount/Fees')).toBeTruthy()
expect(getByTestId('EarnEnterAmount/Fees')).toHaveTextContent('₱0.012')

fireEvent.press(getByText('earnFlow.enterAmount.continue'))

await waitFor(() => expect(AppAnalytics.track).toHaveBeenCalledTimes(1))
expect(AppAnalytics.track).toHaveBeenCalledWith(EarnEvents.earn_enter_amount_continue_press, {
amountEnteredIn: 'token',
amountInUsd: '8.00',
networkId: NetworkId['arbitrum-sepolia'],
depositTokenId: mockArbUsdcTokenId,
providerId: mockEarnPositions[0].appId,
poolId: mockEarnPositions[0].positionId,
fromTokenId: 'arbitrum-sepolia:0x94a9d9ac8a22534e3faca9f4e7f2e2cf85d5e4c8',
fromTokenAmount: '8',
mode: 'withdraw',
})

//TODO(ACT-1389): check navigation to withdrawal confirmation screen
})

it('should allow the user to set an input value over the pool balance if pricePerShare is greater than 1', async () => {
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: undefined,
},
refreshPreparedTransactions: jest.fn(),
clearPreparedTransactions: jest.fn(),
prepareTransactionError: undefined,
isPreparingTransactions: false,
})

const { getByTestId, queryByTestId } = render(
<Provider store={store}>
<MockedNavigator
component={EarnEnterAmount}
params={{ pool: mockPoolWithHighPricePerShare, mode: 'withdraw' }}
/>
</Provider>
)

fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '15')
expect(queryByTestId('EarnEnterAmount/NotEnoughBalanceWarning')).toBeFalsy()
expect(getByTestId('EarnEnterAmount/Continue')).toBeEnabled()
})

it('should not allow the user to set an input amount higher than pool balance * pricePerShare', async () => {
const { getByTestId } = render(
<Provider store={store}>
<MockedNavigator
component={EarnEnterAmount}
params={{ pool: mockPoolWithHighPricePerShare, mode: 'withdraw' }}
/>
</Provider>
)

fireEvent.changeText(getByTestId('EarnEnterAmount/TokenAmountInput'), '20.001')
expect(getByTestId('EarnEnterAmount/NotEnoughBalanceWarning')).toBeTruthy()
expect(getByTestId('EarnEnterAmount/Continue')).toBeDisabled()
})
})

// tests independent of deposit / swap-deposit
it('should show a warning and not allow the user to continue if they input an amount greater than balance', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: undefined,
Expand All @@ -397,7 +549,7 @@ describe('EarnEnterAmount', () => {
})

it('should show loading spinner when preparing transaction', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: undefined,
refreshPreparedTransactions: jest.fn(),
clearPreparedTransactions: jest.fn(),
Expand Down Expand Up @@ -443,6 +595,7 @@ describe('EarnEnterAmount', () => {
const mockStore = createMockStore({
tokens: {
tokenBalances: {
...mockTokenBalances,
[mockArbUsdcTokenId]: {
...mockTokenBalances[mockArbUsdcTokenId],
balance: '100000.42',
Expand All @@ -469,7 +622,7 @@ describe('EarnEnterAmount', () => {
})

it('should track analytics and navigate correctly when tapping cta to add gas', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransactionNotEnough,
swapTransaction: undefined,
Expand Down Expand Up @@ -506,7 +659,7 @@ describe('EarnEnterAmount', () => {
})

it('should show the FeeDetailsBottomSheet when the user taps the fee details icon', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: undefined,
Expand All @@ -531,7 +684,7 @@ describe('EarnEnterAmount', () => {
})

it('should show swap fees on the FeeDetailsBottomSheet when swap transaction is present', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: mockSwapTransaction,
Expand Down Expand Up @@ -562,7 +715,7 @@ describe('EarnEnterAmount', () => {
})

it('should display swap bottom sheet when the user taps the swap details icon', async () => {
jest.mocked(usePrepareDepositTransactions).mockReturnValue({
jest.mocked(usePrepareTransactions).mockReturnValue({
prepareTransactionsResult: {
prepareTransactionsResult: mockPreparedTransaction,
swapTransaction: mockSwapTransaction,
Expand Down
Loading

0 comments on commit b1c5b2c

Please sign in to comment.