Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(earn): support partial withdrawals #6128

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 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 Down
137 changes: 124 additions & 13 deletions src/earn/EarnEnterAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@
import Touchable from 'src/components/Touchable'
import CustomHeader from 'src/components/header/CustomHeader'
import EarnDepositBottomSheet from 'src/earn/EarnDepositBottomSheet'
import { usePrepareDepositTransactions } from 'src/earn/prepareTransactions'
import { EarnDepositMode } from 'src/earn/types'
import {
usePrepareDepositTransactions,
usePrepareWithdrawTransactions,
} from 'src/earn/prepareTransactions'
import { EarnEnterMode } from 'src/earn/types'
import { getSwapToAmountInDecimals } from 'src/earn/utils'
import { CICOFlow } from 'src/fiatExchanges/utils'
import ArrowRightThick from 'src/icons/ArrowRightThick'
Expand Down Expand Up @@ -59,8 +62,9 @@
const MAX_BORDER_RADIUS = 96
const FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME = 250

function useTokens({ pool, mode }: { pool: EarnPosition; mode: EarnDepositMode }) {
function useTokens({ pool, mode }: { pool: EarnPosition; mode: EarnEnterMode }) {
const depositToken = useTokenInfo(pool.dataProps.depositTokenId)
const withdrawToken = useTokenInfo(pool.dataProps.withdrawTokenId)
const swappableTokens = useSelector((state) =>
swappableFromTokensByNetworkIdSelector(state, [pool.networkId])
)
Expand All @@ -81,7 +85,19 @@
throw new Error(`Token info not found for token ID ${pool.dataProps.depositTokenId}`)
}

return mode === 'deposit' ? [depositToken] : eligibleSwappableTokens
if (!withdrawToken) {
// should never happen
throw new Error(`Token info not found for token ID ${pool.dataProps.withdrawTokenId}`)
}

switch (mode) {
case 'deposit':
return [depositToken]
case 'withdraw':
return [withdrawToken]
case 'swap-deposit':
return eligibleSwappableTokens
}
}

function EarnEnterAmount({ route }: Props) {
Expand All @@ -102,6 +118,7 @@

const [tokenAmountInput, setTokenAmountInput] = useState<string>('')
const [localAmountInput, setLocalAmountInput] = useState<string>('')
const [maxPressed, setMaxPressed] = useState(false)
const [enteredIn, setEnteredIn] = useState<AmountEnteredIn>('token')
// this should never be null, just adding a default to make TS happy
const localCurrencySymbol = useSelector(getLocalCurrencySymbol) ?? LocalCurrencySymbol.USD
Expand All @@ -122,13 +139,17 @@
// NOTE: analytics is already fired by the bottom sheet, don't need one here
}

// Avoid conditionally calling hooks
const depositTransaction = usePrepareDepositTransactions()
const withdrawTransaction = usePrepareWithdrawTransactions()

const {
prepareTransactionsResult: { prepareTransactionsResult, swapTransaction } = {},
refreshPreparedTransactions,
clearPreparedTransactions,
prepareTransactionError,
isPreparingTransactions,
} = usePrepareDepositTransactions()
} = mode === 'withdraw' ? withdrawTransaction : depositTransaction

const walletAddress = useSelector(walletAddressSelector)

Expand All @@ -150,6 +171,7 @@
pool,
hooksApiUrl,
shortcutId: mode,
useMax: maxPressed,
})
}

Expand Down Expand Up @@ -225,7 +247,10 @@
const { estimatedFeeAmount, feeCurrency, maxFeeAmount } =
getFeeCurrencyAndAmounts(prepareTransactionsResult)

const isAmountLessThanBalance = tokenAmount && tokenAmount.lte(token.balance)
const isAmountLessThanBalance =
mode === 'withdraw'
? tokenAmount && tokenAmount.lte(pool.balance)
: tokenAmount && tokenAmount.lte(token.balance)
const showNotEnoughBalanceForGasWarning =
isAmountLessThanBalance &&
prepareTransactionsResult &&
Expand All @@ -241,6 +266,7 @@
!!tokenAmount?.isZero() || !transactionIsPossible

const onTokenAmountInputChange = (value: string) => {
setMaxPressed(false)
if (!value) {
setTokenAmountInput('')
setEnteredIn('token')
Expand All @@ -256,6 +282,7 @@
}

const onLocalAmountInputChange = (value: string) => {
setMaxPressed(false)
// remove leading currency symbol and grouping separators
if (value.startsWith(localCurrencySymbol)) {
value = value.slice(1)
Expand All @@ -282,8 +309,13 @@
// eventually we may want to do something smarter here, like subtracting gas fees from the max amount if
// this is a gas-paying token. for now, we are just showing a warning to the user prompting them to lower the amount
// if there is not enough for gas
setTokenAmountInput(token.balance.toFormat({ decimalSeparator }))
if (mode === 'withdraw') {
setTokenAmountInput(new BigNumber(pool.balance).toFormat({ decimalSeparator }))
} else {
setTokenAmountInput(token.balance.toFormat({ decimalSeparator }))
}
setEnteredIn('token')
setMaxPressed(true)
tokenAmountInputRef.current?.blur()
localAmountInputRef.current?.blur()
AppAnalytics.track(SendEvents.max_pressed, {
Expand Down Expand Up @@ -312,6 +344,7 @@
? getSwapToAmountInDecimals({ swapTransaction, fromAmount: tokenAmount }).toString()
: tokenAmount.toString(),
})
// TODO(ACT-1389) if mode === 'withdraw' navigate to EarnConfirmationScreen
reviewBottomSheetRef.current?.snapToIndex(0)
}

Expand All @@ -320,7 +353,11 @@
<CustomHeader style={{ paddingHorizontal: Spacing.Thick24 }} left={<BackButton />} />
<KeyboardAwareScrollView contentContainerStyle={styles.contentContainer}>
<View style={styles.inputContainer}>
<Text style={styles.title}>{t('earnFlow.enterAmount.title')}</Text>
<Text style={styles.title}>
{mode === 'withdraw'
? t('earnFlow.enterAmount.titleWithdraw')
: t('earnFlow.enterAmount.title')}
</Text>
<View style={styles.inputBox}>
<View style={styles.inputRow}>
<AmountInput
Expand Down Expand Up @@ -368,17 +405,26 @@
)}
</View>
</View>
{tokenAmount && prepareTransactionsResult && (
<TransactionDetails
{tokenAmount && prepareTransactionsResult && mode !== 'withdraw' && (
<TransactionDepositDetails
pool={pool}
token={token}
tokenAmount={tokenAmount}
prepareTransactionsResult={prepareTransactionsResult}
feeDetailsBottomSheetRef={feeDetailsBottomSheetRef}
swapDetailsBottomSheetRef={swapDetailsBottomSheetRef}
swapTransaction={swapTransaction}

Check failure on line 416 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Mobile

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.

Check failure on line 416 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Android / Android (SDK 34)

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.

Check failure on line 416 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / iOS / iOS (15.0)

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.
/>
)}
{mode === 'withdraw' && (
<TransactionWithdrawDetails
pool={pool}
token={token}
tokenAmount={tokenAmount}

Check failure on line 423 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Mobile

Type '{ pool: EarnPosition; token: TokenBalance; tokenAmount: BigNumber | null; prepareTransactionsResult: PreparedTransactionsResult | undefined; feeDetailsBottomSheetRef: RefObject<...>; }' is not assignable to type 'IntrinsicAttributes & { pool: EarnPosition; token: TokenBalance; prepareTransactionsResult?: PreparedTransactionsResult | undefined; feeDetailsBottomSheetRef: RefObject<...>; }'.

Check failure on line 423 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Android / Android (SDK 34)

Type '{ pool: EarnPosition; token: TokenBalance; tokenAmount: BigNumber | null; prepareTransactionsResult: PreparedTransactionsResult | undefined; feeDetailsBottomSheetRef: RefObject<...>; }' is not assignable to type 'IntrinsicAttributes & { pool: EarnPosition; token: TokenBalance; prepareTransactionsResult?: PreparedTransactionsResult | undefined; feeDetailsBottomSheetRef: RefObject<...>; }'.

Check failure on line 423 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / iOS / iOS (15.0)

Type '{ pool: EarnPosition; token: TokenBalance; tokenAmount: BigNumber | null; prepareTransactionsResult: PreparedTransactionsResult | undefined; feeDetailsBottomSheetRef: RefObject<...>; }' is not assignable to type 'IntrinsicAttributes & { pool: EarnPosition; token: TokenBalance; prepareTransactionsResult?: PreparedTransactionsResult | undefined; feeDetailsBottomSheetRef: RefObject<...>; }'.
prepareTransactionsResult={prepareTransactionsResult}
feeDetailsBottomSheetRef={feeDetailsBottomSheetRef}
/>
)}
</View>
{showNotEnoughBalanceForGasWarning && (
<InLineNotification
Expand Down Expand Up @@ -425,7 +471,7 @@
testID="EarnEnterAmount/NotEnoughBalanceWarning"
/>
)}
{prepareTransactionError && (
{prepareTransactionError && mode !== 'withdraw' && (
<InLineNotification
variant={NotificationVariant.Error}
title={t('sendEnterAmountScreen.prepareTransactionError.title')}
Expand All @@ -452,7 +498,7 @@
feeCurrency={feeCurrency}
estimatedFeeAmount={estimatedFeeAmount}
maxFeeAmount={maxFeeAmount}
swapTransaction={swapTransaction}

Check failure on line 501 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Mobile

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.

Check failure on line 501 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Android / Android (SDK 34)

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.

Check failure on line 501 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / iOS / iOS (15.0)

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.
pool={pool}
token={token}
tokenAmount={tokenAmount}
Expand All @@ -476,7 +522,7 @@
inputAmount={tokenAmount}
pool={pool}
mode={mode}
swapTransaction={swapTransaction}

Check failure on line 525 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Mobile

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.

Check failure on line 525 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / Android / Android (SDK 34)

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.

Check failure on line 525 in src/earn/EarnEnterAmount.tsx

View workflow job for this annotation

GitHub Actions / iOS / iOS (15.0)

Type 'SwapTransaction | null | undefined' is not assignable to type 'SwapTransaction | undefined'.
inputTokenId={token.tokenId}
/>
)}
Expand All @@ -492,7 +538,72 @@
)
}

function TransactionDetails({
function TransactionWithdrawDetails({
pool,
token,
prepareTransactionsResult,
feeDetailsBottomSheetRef,
}: {
pool: EarnPosition
token: TokenBalance
prepareTransactionsResult?: PreparedTransactionsResult
feeDetailsBottomSheetRef: React.RefObject<BottomSheetModalRefType>
}) {
const { t } = useTranslation()
const { maxFeeAmount, feeCurrency } = getFeeCurrencyAndAmounts(prepareTransactionsResult)

return (
<View style={styles.txDetailsContainer} testID="EnterAmountWithdrawInfoCard">
<View style={styles.txDetailsLineItem}>
<LabelWithInfo
label={t('earnFlow.enterAmount.available')}
testID="LabelWithInfo/AvailableLabel"
/>
<View style={styles.txDetailsValue}>
<TokenDisplay
tokenId={token.tokenId}
testID="EarnEnterAmount/Deposit/Crypto"
amount={pool.balance}
showLocalAmount={true}
style={styles.txDetailsValueText}
/>
<Text style={[styles.txDetailsValueText, styles.gray4]}>
{'('}
<TokenDisplay
testID="EarnEnterAmount/Deposit/Fiat"
tokenId={token.tokenId}
amount={pool.balance}
showLocalAmount={false}
/>
{')'}
</Text>
</View>
</View>
{feeCurrency && maxFeeAmount && (
<View style={styles.txDetailsLineItem}>
<LabelWithInfo
label={t('earnFlow.enterAmount.fees')}
onPress={() => {
feeDetailsBottomSheetRef?.current?.snapToIndex(0)
}}
testID="LabelWithInfo/FeeLabel"
/>
<View style={styles.txDetailsValue}>
<TokenDisplay
testID="EarnEnterAmount/Fees"
tokenId={feeCurrency.tokenId}
// TODO: add swap fees to this amount
amount={maxFeeAmount.toString()}
style={styles.txDetailsValueText}
/>
</View>
</View>
)}
</View>
)
}

function TransactionDepositDetails({
pool,
token,
tokenAmount,
Expand Down Expand Up @@ -523,7 +634,7 @@
return (
feeCurrency &&
maxFeeAmount && (
<View style={styles.txDetailsContainer} testID="EnterAmountInfoCard">
<View style={styles.txDetailsContainer} testID="EnterAmountDepositInfoCard">
{swapTransaction && (
<View style={styles.txDetailsLineItem}>
<LabelWithInfo
Expand Down
36 changes: 25 additions & 11 deletions src/earn/EarnPoolInfoScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -418,9 +418,11 @@ function LearnMoreTouchable({
function ActionButtons({
earnPosition,
onPressDeposit,
onPressWithdraw,
}: {
earnPosition: EarnPosition
onPressDeposit: () => void
onPressWithdraw: () => void
}) {
const { bottom } = useSafeAreaInsets()
const insetsStyle = {
Expand All @@ -437,16 +439,7 @@ function ActionButtons({
{withdraw && (
<Button
text={t('earnFlow.poolInfoScreen.withdraw')}
onPress={() => {
AppAnalytics.track(EarnEvents.earn_pool_info_tap_withdraw, {
poolId: earnPosition.positionId,
providerId: earnPosition.appId,
poolAmount: earnPosition.balance,
networkId: earnPosition.networkId,
depositTokenId: earnPosition.dataProps.depositTokenId,
})
navigate(Screens.EarnCollectScreen, { pool: earnPosition })
}}
onPress={onPressWithdraw}
size={BtnSizes.FULL}
type={BtnTypes.SECONDARY}
style={styles.flex}
Expand Down Expand Up @@ -490,6 +483,23 @@ export default function EarnPoolInfoScreen({ route, navigation }: Props) {
exchanges,
} = useDepositEntrypointInfo({ allTokens, pool })

const onPressWithdraw = () => {
AppAnalytics.track(EarnEvents.earn_pool_info_tap_withdraw, {
poolId: positionId,
providerId: appId,
poolAmount: balance,
networkId,
depositTokenId: dataProps.depositTokenId,
})
// TODO(Tomm): is a feature flag for partial withdrawals needed?
const partialWithdrawalsEnabled = true
if (partialWithdrawalsEnabled) {
navigate(Screens.EarnEnterAmount, { pool, mode: 'withdraw' })
} else {
navigate(Screens.EarnCollectScreen, { pool })
}
}

const onPressDeposit = () => {
AppAnalytics.track(EarnEvents.earn_pool_info_tap_deposit, {
poolId: positionId,
Expand Down Expand Up @@ -611,7 +621,11 @@ export default function EarnPoolInfoScreen({ route, navigation }: Props) {
) : null}
</View>
</Animated.ScrollView>
<ActionButtons earnPosition={pool} onPressDeposit={onPressDeposit} />
<ActionButtons
earnPosition={pool}
onPressDeposit={onPressDeposit}
onPressWithdraw={onPressWithdraw}
/>
<InfoBottomSheet
infoBottomSheetRef={depositInfoBottomSheetRef}
titleKey="earnFlow.poolInfoScreen.depositAndEarnings"
Expand Down
Loading
Loading