Skip to content

Commit

Permalink
feat: simplified allowance approval flow for limit orders (#8383)
Browse files Browse the repository at this point in the history
  • Loading branch information
woodenfurniture authored Dec 17, 2024
1 parent 97ecf10 commit 82bbe67
Show file tree
Hide file tree
Showing 6 changed files with 417 additions and 19 deletions.
7 changes: 6 additions & 1 deletion src/assets/translations/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -1067,7 +1067,12 @@
"insufficientValidTo": "Order expiry too soon",
"excessiveValidTo": "Order expiry too far into the future",
"insufficientLiquidity": "Insufficient liquidity",
"zeroFunds": "Non-zero sell asset balance needed"
"zeroFunds": "Non-zero sell asset balance needed",
"insufficientFundsForGas": "Insufficient funds for gas"
},
"usdtAllowanceReset": {
"title": "Unable to approve allowance for USDT",
"description": "Your current USDT allowance on Ethereum needs to be reset to 0 before it can be set to a sufficient value. Please revoke the current USDT token allowance and try again."
},
"openOrders": "Open Orders",
"orderHistory": "Order History",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MemoryRouter, Route, Switch, useLocation } from 'react-router'
import type { TradeInputTab } from 'components/MultiHopTrade/types'

import { SlideTransitionRoute } from '../SlideTransitionRoute'
import { AllowanceApproval } from './components/AllowanceApproval'
import { LimitOrderConfirm } from './components/LimitOrderConfirm'
import { LimitOrderInput } from './components/LimitOrderInput'
import { LimitOrderList } from './components/LimitOrderList'
Expand Down Expand Up @@ -42,8 +43,7 @@ export const LimitOrder = ({ isCompact, tradeInputRef, onChangeTab }: LimitOrder
}, [])

const renderAllowanceApproval = useCallback(() => {
// TODO: Implement me!
return null
return <AllowanceApproval />
}, [])

const renderPlaceOrder = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { Button, Card, CardBody, CardFooter, CardHeader, Heading, Link } from '@chakra-ui/react'
import { COW_SWAP_VAULT_RELAYER_ADDRESS } from '@shapeshiftoss/swapper'
import { TxStatus } from '@shapeshiftoss/unchained-client'
import { bnOrZero, fromBaseUnit } from '@shapeshiftoss/utils'
import { useCallback, useMemo, useState } from 'react'
import { useTranslate } from 'react-polyglot'
import { useHistory } from 'react-router'
import { Amount } from 'components/Amount/Amount'
import { SlideTransition } from 'components/SlideTransition'
import { Text } from 'components/Text'
import type { TextPropTypes } from 'components/Text/Text'
import { useIsAllowanceResetRequired } from 'hooks/queries/useIsAllowanceResetRequired'
import { useSafeTxQuery } from 'hooks/queries/useSafeTx'
import { useErrorToast } from 'hooks/useErrorToast/useErrorToast'
import { getTxLink } from 'lib/getTxLink'
import { selectActiveQuote } from 'state/slices/limitOrderSlice/selectors'
import type { LimitOrderActiveQuote } from 'state/slices/limitOrderSlice/types'
import {
selectAssetById,
selectFeeAssetById,
selectPortfolioCryptoBalanceBaseUnitByFilter,
} from 'state/slices/selectors'
import { useAppSelector, useSelectorWithArgs } from 'state/store'

import { StatusBody } from '../../StatusBody'
import { WithBackButton } from '../../WithBackButton'
import { useAllowanceApproval } from '../hooks/useAllowanceApproval'
import { LimitOrderRoutePaths } from '../types'

const cardBorderRadius = { base: '2xl' }

const AllowanceApprovalInner = ({ activeQuote }: { activeQuote: LimitOrderActiveQuote }) => {
const history = useHistory()
const translate = useTranslate()
const { showErrorToast } = useErrorToast()
const [txStatus, setTxStatus] = useState(TxStatus.Unknown)
const [txHash, setTxHash] = useState('')

const sellAsset = useSelectorWithArgs(selectAssetById, activeQuote.params.sellAssetId)
const feeAsset = useSelectorWithArgs(selectFeeAssetById, sellAsset?.assetId ?? '')
const filter = useMemo(
() => ({
accountId: activeQuote.params.accountId,
assetId: feeAsset?.assetId ?? '',
}),
[activeQuote.params.accountId, feeAsset?.assetId],
)
const feeAssetBalance = useSelectorWithArgs(selectPortfolioCryptoBalanceBaseUnitByFilter, filter)

const onMutate = useCallback(() => {
setTxStatus(TxStatus.Pending)
}, [])

const onError = useCallback(
(err: Error) => {
showErrorToast(err)
setTxStatus(TxStatus.Failed)
},
[showErrorToast],
)

const onSuccess = useCallback(() => {
setTxStatus(TxStatus.Confirmed)
history.push(LimitOrderRoutePaths.Confirm)
}, [history])

const {
approveMutation,
approvalNetworkFeeCryptoBaseUnit,
isLoading: isAllowanceApprovalLoading,
} = useAllowanceApproval({
activeQuote,
setTxHash,
feeQueryEnabled: true,
isInitiallyRequired: true,
onMutate,
onError,
onSuccess,
})

const { isAllowanceResetRequired, isLoading: isAllowanceResetRequiredLoading } =
useIsAllowanceResetRequired({
assetId: activeQuote.params.sellAssetId,
amountCryptoBaseUnit: activeQuote.params.sellAmountCryptoBaseUnit,
from: activeQuote.params.sellAccountAddress,
spender: COW_SWAP_VAULT_RELAYER_ADDRESS,
})

const isLoading = useMemo(() => {
return (
txStatus === TxStatus.Pending || isAllowanceApprovalLoading || isAllowanceResetRequiredLoading
)
}, [isAllowanceApprovalLoading, isAllowanceResetRequiredLoading, txStatus])

const handleSignAndBroadcast = useCallback(async () => {
await approveMutation.mutateAsync()
}, [approveMutation])

const handleGoBack = useCallback(() => {
history.push(LimitOrderRoutePaths.Input)
}, [history])

const { data: maybeSafeTx } = useSafeTxQuery({
maybeSafeTxHash: txHash,
accountId: activeQuote.params.accountId,
})

const txLink = useMemo(() => {
if (!feeAsset) return
if (!txHash) return

return getTxLink({
defaultExplorerBaseUrl: feeAsset.explorerTxLink,
maybeSafeTx,
tradeId: txHash,
accountId: activeQuote.params.accountId,
})
}, [activeQuote.params.accountId, feeAsset, maybeSafeTx, txHash])

const hasSufficientBalanceForGas = useMemo(() => {
if (approvalNetworkFeeCryptoBaseUnit === undefined) {
return isLoading
}

return bnOrZero(feeAssetBalance).gte(approvalNetworkFeeCryptoBaseUnit)
}, [approvalNetworkFeeCryptoBaseUnit, feeAssetBalance, isLoading])

const approveAssetTranslation = useMemo(() => {
return [
'trade.approveAsset',
{ symbol: sellAsset?.symbol ?? '' },
] as TextPropTypes['translation']
}, [sellAsset])

const { buttonTranslation, isError } = useMemo(() => {
if (!hasSufficientBalanceForGas) {
return { buttonTranslation: 'limitOrder.errors.insufficientFundsForGas', isError: true }
}

return { buttonTranslation: approveAssetTranslation, isError: isAllowanceResetRequired }
}, [approveAssetTranslation, hasSufficientBalanceForGas, isAllowanceResetRequired])

const statusBody = useMemo(() => {
const statusTranslation = (() => {
switch (txStatus) {
case TxStatus.Failed:
return 'common.somethingWentWrong'
case TxStatus.Pending:
case TxStatus.Unknown:
case TxStatus.Confirmed:
default:
return null
}
})()

const defaultTitleTranslation = isAllowanceResetRequired
? 'limitOrder.usdtAllowanceReset.title'
: approveAssetTranslation

return (
<StatusBody txStatus={txStatus} defaultTitleTranslation={defaultTitleTranslation}>
<>
<Text translation={statusTranslation} color='text.subtle' />
{Boolean(isAllowanceResetRequired) && (
<>
<Text translation='limitOrder.usdtAllowanceReset.description' color='text.subtle' />
</>
)}
{!isAllowanceResetRequired && txStatus === TxStatus.Unknown && (
<>
<Text translation='common.approvalFee' color='text.subtle' />
{approvalNetworkFeeCryptoBaseUnit && feeAsset && (
<Amount.Crypto
value={fromBaseUnit(approvalNetworkFeeCryptoBaseUnit, feeAsset?.precision)}
symbol={feeAsset?.symbol ?? ''}
/>
)}
</>
)}
{Boolean(txLink) && (
<Button as={Link} href={txLink} size='sm' variant='link' colorScheme='blue' isExternal>
{translate('limitOrder.viewOnChain')}
</Button>
)}
</>
</StatusBody>
)
}, [
isAllowanceResetRequired,
approvalNetworkFeeCryptoBaseUnit,
approveAssetTranslation,
feeAsset,
translate,
txLink,
txStatus,
])

return (
<SlideTransition>
<Card
flex={1}
borderRadius={cardBorderRadius}
variant='dashboard'
width='500px'
borderColor='border.base'
bg='background.surface.raised.base'
>
<CardHeader px={6} pt={4}>
<WithBackButton onBack={handleGoBack}>
<Heading textAlign='center' fontSize='md'>
<Text translation='trade.allowance' />
</Heading>
</WithBackButton>
</CardHeader>
<CardBody py={32}>{statusBody}</CardBody>
<CardFooter flexDir='row' gap={4} px={4} borderTopWidth={0}>
<Button
colorScheme={isError ? 'red' : 'blue'}
size='lg'
width='full'
onClick={handleSignAndBroadcast}
isLoading={isLoading}
isDisabled={isLoading || isError}
>
<Text translation={buttonTranslation} />
</Button>
</CardFooter>
</Card>
</SlideTransition>
)
}

export const AllowanceApproval = () => {
const history = useHistory()
const activeQuote = useAppSelector(selectActiveQuote)

// This should never happen but for paranoia and typescript reasons:
if (activeQuote === undefined) {
console.error('Attempted to perform allowance approval on non-existent quote')
history.push(LimitOrderRoutePaths.Input)
return null
}

return <AllowanceApprovalInner activeQuote={activeQuote} />
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Button, Divider, HStack, Stack, useMediaQuery } from '@chakra-ui/react'
import { skipToken } from '@reduxjs/toolkit/query'
import type { ChainId } from '@shapeshiftoss/caip'
import { fromAccountId } from '@shapeshiftoss/caip'
import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip'
import {
COW_SWAP_VAULT_RELAYER_ADDRESS,
getCowNetwork,
getDefaultSlippageDecimalPercentageForSwapper,
SwapperName,
Expand All @@ -21,7 +22,9 @@ import { Text } from 'components/Text'
import { useAccountsFetchQuery } from 'context/AppProvider/hooks/useAccountsFetchQuery'
import { WalletActions } from 'context/WalletProvider/actions'
import { useActions } from 'hooks/useActions'
import { useErrorToast } from 'hooks/useErrorToast/useErrorToast'
import { useWallet } from 'hooks/useWallet/useWallet'
import { getErc20Allowance } from 'lib/utils/evm'
import { useQuoteLimitOrderQuery } from 'state/apis/limit-orders/limitOrderApi'
import { selectCalculatedFees, selectIsVotingPowerLoading } from 'state/apis/snapshot/selectors'
import { LimitPriceMode } from 'state/slices/limitOrderInputSlice/constants'
Expand Down Expand Up @@ -89,6 +92,7 @@ export const LimitOrderInput = ({

const history = useHistory()
const { handleSubmit } = useFormContext()
const { showErrorToast } = useErrorToast()
const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false })

const userSlippagePercentageDecimal = useAppSelector(selectUserSlippagePercentageDecimal)
Expand Down Expand Up @@ -150,6 +154,7 @@ export const LimitOrderInput = ({
})

const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false)
const [isCheckingAllowance, setIsCheckingAllowance] = useState(false)

const isAnyAccountMetadataLoadedForChainIdFilter = useMemo(
() => ({ chainId: sellAsset.chainId }),
Expand Down Expand Up @@ -251,7 +256,7 @@ export const LimitOrderInput = ({
}
}, [limitPriceMode, marketPriceBuyAsset, setLimitPrice])

const onSubmit = useCallback(() => {
const onSubmit = useCallback(async () => {
// No preview happening if wallet isn't connected i.e is using the demo wallet
if (!isConnected || isDemoWallet) {
return handleConnect()
Expand All @@ -278,18 +283,46 @@ export const LimitOrderInput = ({
response: quoteResponse,
})

history.push(LimitOrderRoutePaths.Confirm)
const { assetReference, chainId } = fromAssetId(limitOrderQuoteParams.sellAssetId)

// Trigger loading state while we check the allowance
setIsCheckingAllowance(true)

try {
// Check the ERC20 token allowance
const allowanceOnChainCryptoBaseUnit = await getErc20Allowance({
address: assetReference,
spender: COW_SWAP_VAULT_RELAYER_ADDRESS,
from: limitOrderQuoteParams.sellAccountAddress as Address,
chainId,
})

// If approval is required, route there
if (bn(allowanceOnChainCryptoBaseUnit).lt(limitOrderQuoteParams.sellAmountCryptoBaseUnit)) {
history.push(LimitOrderRoutePaths.AllowanceApproval)
return
}

// Otherwise, proceed with confirmation
history.push(LimitOrderRoutePaths.Confirm)
return
} catch (e) {
showErrorToast(e)
} finally {
setIsCheckingAllowance(false)
}
}, [
buyAmountCryptoBaseUnit,
expiry,
handleConnect,
history,
isConnected,
isDemoWallet,
limitOrderQuoteParams,
quoteResponse,
setActiveQuote,
limitOrderQuoteParams,
sellAccountId,
setActiveQuote,
expiry,
buyAmountCryptoBaseUnit,
handleConnect,
history,
showErrorToast,
])

const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit])
Expand All @@ -312,6 +345,7 @@ export const LimitOrderInput = ({

const isLoading = useMemo(() => {
return (
isCheckingAllowance ||
isLimitOrderQuoteFetching ||
// No account meta loaded for that chain
!isAnyAccountMetadataLoadedForChainId ||
Expand All @@ -322,6 +356,7 @@ export const LimitOrderInput = ({
isVotingPowerLoading
)
}, [
isCheckingAllowance,
isAnyAccountMetadataLoadedForChainId,
isLimitOrderQuoteFetching,
isTradeQuoteRequestAborted,
Expand Down
Loading

0 comments on commit 82bbe67

Please sign in to comment.