diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index b666300b37b..197f071b082 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -1071,6 +1071,8 @@ "noOpenOrders": "No open orders yet.", "noHistoricalOrders": "No historical orders yet.", "limitPriceIsPercentLowerThanMarket": "Limit price is %{percent}% lower than market price", + "awaitingOrderPlacement": "Awaiting order placement on %{swapperName}", + "orderPlacement": "Place order on %{swapperName}", "expiryOption": { "oneHour": "1 hour", "oneDay": "1 day", diff --git a/src/components/MultiHopTrade/components/LImitOrderShared/InnerSteps.tsx b/src/components/MultiHopTrade/components/LImitOrderShared/InnerSteps.tsx new file mode 100644 index 00000000000..539612ce6f2 --- /dev/null +++ b/src/components/MultiHopTrade/components/LImitOrderShared/InnerSteps.tsx @@ -0,0 +1,256 @@ +import { ArrowUpDownIcon, CheckCircleIcon, WarningIcon } from '@chakra-ui/icons' +import { Box, Center, Collapse, Flex, HStack, Skeleton, StepStatus } from '@chakra-ui/react' +import { fromAccountId, fromAssetId } from '@shapeshiftoss/caip' +import { COW_SWAP_VAULT_RELAYER_ADDRESS, SwapperName } from '@shapeshiftoss/swapper' +import { bn } from '@shapeshiftoss/utils' +import type Polyglot from 'node-polyglot' +import { useEffect, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' +import type { Address } from 'viem' +import { AnimatedCheck } from 'components/AnimatedCheck' +import { CircularProgress } from 'components/CircularProgress/CircularProgress' +import { Text } from 'components/Text' +import { useErrorToast } from 'hooks/useErrorToast/useErrorToast' +import { getErc20Allowance } from 'lib/utils/evm' +import { usePlaceLimitOrderMutation } from 'state/apis/limit-orders/limitOrderApi' +import { + selectInputSellAmountCryptoBaseUnit, + selectSellAccountId, +} from 'state/slices/limitOrderInputSlice/selectors' +import { LimitOrderSubmissionState } from 'state/slices/limitOrderSlice/constants' +import { + selectActiveQuote, + selectActiveQuoteId, + selectActiveQuoteSellAsset, + selectLimitOrderSubmissionMetadata, +} from 'state/slices/limitOrderSlice/selectors' +import { TransactionExecutionState } from 'state/slices/tradeQuoteSlice/types' +import { useAppSelector, useSelectorWithArgs } from 'state/store' + +import { useAllowanceApproval } from '../LimitOrder/hooks/useAllowanceApproval' +import { StepperStep } from '../MultiHopTradeConfirm/components/StepperStep' +import { TxLabel } from '../TradeConfirm/TxLabel' + +const collapseStyle = { width: '100%' } +const stepProps = { py: 0, pr: 2, pl: 0 } + +const erroredStepIndicator = +const completedStepIndicator = + +export const InnerSteps = () => { + const { showErrorToast } = useErrorToast() + const translate = useTranslate() + + const [isExpanded, setIsExpanded] = useState(false) + const [isLoadingAllowance, setIsLoadingAllowance] = useState(false) + const [isAllowanceApprovalRequired, setIsAllowanceApprovalRequired] = useState(false) + + const sellAsset = useAppSelector(selectActiveQuoteSellAsset) + const sellAccountId = useAppSelector(selectSellAccountId) + const sellAmountCryptoBaseUnit = useAppSelector(selectInputSellAmountCryptoBaseUnit) + // const activeQuote = useAppSelector(selectActiveQuote) + const quoteId = useAppSelector(selectActiveQuoteId) + + // useAllowanceApproval({ + // activeQuote, + // setTxHash, + // feeQueryEnabled: true, + // isInitiallyRequired: true, + // onMutate, + // onError, + // onSuccess, + // }) + + const [_, { data: approvalData, error: approvalError }] = usePlaceLimitOrderMutation() + + const orderSubmissionMetadataFilter = useMemo(() => { + return { quoteId: quoteId ?? 0 } + }, [quoteId]) + + const { + state: orderSubmissionState, + allowanceReset, + allowanceApproval, + } = useSelectorWithArgs(selectLimitOrderSubmissionMetadata, orderSubmissionMetadataFilter) + + useEffect(() => { + if (!sellAsset || !sellAccountId) return + const { assetReference, chainId } = fromAssetId(sellAsset.assetId) + const sellAccountAddress = fromAccountId(sellAccountId).account as Address + + ;(async () => { + setIsLoadingAllowance(true) + + try { + // Check the ERC20 token allowance + const allowanceOnChainCryptoBaseUnit = await getErc20Allowance({ + address: assetReference, + spender: COW_SWAP_VAULT_RELAYER_ADDRESS, + from: sellAccountAddress as Address, + chainId, + }) + + // If approval is required + if (bn(allowanceOnChainCryptoBaseUnit).lt(sellAmountCryptoBaseUnit)) { + setIsAllowanceApprovalRequired(true) + } + + return + } catch (e) { + showErrorToast(e) + } finally { + setIsLoadingAllowance(false) + } + })() + }, [ + isAllowanceApprovalRequired, + sellAccountId, + sellAmountCryptoBaseUnit, + sellAsset, + showErrorToast, + ]) + + const summaryStepIndicator = useMemo(() => { + switch (true) { + case !!approvalData: + return ( +
+ +
+ ) + case !!approvalError: + return ( +
+ +
+ ) + default: + return ( +
+ +
+ ) + } + }, [approvalData, approvalError]) + + const summaryStepProps = useMemo( + () => ({ + py: 0, + onClick: () => setIsExpanded(!isExpanded), + cursor: 'pointer', + 'data-expanded': isExpanded, + }), + [isExpanded], + ) + + const titleTranslation: string | [string, number | Polyglot.InterpolationOptions] | null = + useMemo(() => { + switch (orderSubmissionState) { + case LimitOrderSubmissionState.AwaitingAllowanceReset: + return 'limitOrder.awaitingAllowanceReset' + case LimitOrderSubmissionState.AwaitingAllowanceApproval: + return 'limitOrder.awaitingApproval' + case LimitOrderSubmissionState.AwaitingLimitOrderSubmission: + return ['limitOrder.awaitingOrderPlacement', { swapperName: SwapperName.CowSwap }] + default: + return null + } + }, [orderSubmissionState]) + + const titleElement = useMemo(() => { + return ( + + + + + + + ) + }, [titleTranslation]) + + const stepIndicator = useMemo( + () => ( + + ), + [approvalError], + ) + + const allowanceResetTitle = useMemo(() => { + return ( + + + {allowanceReset.txHash && sellAsset && sellAccountId && ( + + )} + + ) + }, [allowanceReset.txHash, sellAccountId, sellAsset]) + + const allowanceApprovalTitle = useMemo(() => { + return ( + + + {allowanceApproval.txHash && sellAsset && sellAccountId && ( + + )} + + ) + }, [allowanceApproval.txHash, sellAccountId, sellAsset]) + + return ( + + + + + {allowanceReset.isRequired && ( + + )} + {isAllowanceApprovalRequired && ( + + )} + + + + + ) +} diff --git a/src/components/MultiHopTrade/components/LImitOrderShared/LimitOrderConfirm.tsx b/src/components/MultiHopTrade/components/LImitOrderShared/LimitOrderConfirm.tsx index 77e0ccfa365..b54032d855f 100644 --- a/src/components/MultiHopTrade/components/LImitOrderShared/LimitOrderConfirm.tsx +++ b/src/components/MultiHopTrade/components/LImitOrderShared/LimitOrderConfirm.tsx @@ -1,43 +1,34 @@ -import { InfoIcon } from '@chakra-ui/icons' -import { Button, Card, HStack, Stack } from '@chakra-ui/react' -import { SwapperName } from '@shapeshiftoss/swapper' +import { Button } from '@chakra-ui/react' import { useCallback, useMemo } from 'react' -import { useTranslate } from 'react-polyglot' import { useHistory } from 'react-router-dom' -import { Amount } from 'components/Amount/Amount' -import { Row } from 'components/Row/Row' -import { RawText, Text } from 'components/Text/Text' -import { TransactionDate } from 'components/TransactionHistoryRows/TransactionDate' +import { Text } from 'components/Text/Text' import { usePlaceLimitOrderMutation } from 'state/apis/limit-orders/limitOrderApi' +import { + selectBuyAmountCryptoBaseUnit, + selectInputSellAmountCryptoBaseUnit, +} from 'state/slices/limitOrderInputSlice/selectors' import { selectActiveQuote, selectActiveQuoteBuyAsset, - selectActiveQuoteExpirationTimestamp, - selectActiveQuoteFeeAsset, - selectActiveQuoteLimitPrice, - selectActiveQuoteNetworkFeeCryptoPrecision, - selectActiveQuoteNetworkFeeUserCurrency, selectActiveQuoteSellAsset, } from 'state/slices/limitOrderSlice/selectors' import { useAppSelector } from 'state/store' import { LimitOrderRoutePaths } from '../LimitOrder/types' import { SharedConfirm } from '../SharedConfirm/SharedConfirm' +import { SharedConfirmBody } from '../SharedConfirm/SharedConfirmBody' import { SharedConfirmFooter } from '../SharedConfirm/SharedConfirmFooter' -import { SwapperIcon } from '../TradeInput/components/SwapperIcon/SwapperIcon' +import { InnerSteps } from './InnerSteps' +import { LimitOrderDetail } from './LimitOrderDetail' export const LimitOrderConfirm = () => { const history = useHistory() - const translate = useTranslate() const activeQuote = useAppSelector(selectActiveQuote) const sellAsset = useAppSelector(selectActiveQuoteSellAsset) const buyAsset = useAppSelector(selectActiveQuoteBuyAsset) - const feeAsset = useAppSelector(selectActiveQuoteFeeAsset) - const networkFeeCryptoPrecision = useAppSelector(selectActiveQuoteNetworkFeeCryptoPrecision) - const networkFeeUserCurrency = useAppSelector(selectActiveQuoteNetworkFeeUserCurrency) - const limitPrice = useAppSelector(selectActiveQuoteLimitPrice) - const quoteExpirationTimestamp = useAppSelector(selectActiveQuoteExpirationTimestamp) + const sellAmountCryptoBaseUnit = useAppSelector(selectInputSellAmountCryptoBaseUnit) + const buyAmountCryptoBaseUnit = useAppSelector(selectBuyAmountCryptoBaseUnit) const handleBack = useCallback(() => { history.push(LimitOrderRoutePaths.Input) @@ -50,81 +41,21 @@ export const LimitOrderConfirm = () => { }, []) const body = useMemo(() => { - return
LimitOrderConfirm
- }, []) - - const detail = useMemo(() => { + if (!sellAsset || !buyAsset) return null return ( - - - - - - - - - = - - - - - - - - - - - - {SwapperName.CowSwap} - - - - - - - - - - - - - - - - - - - - - - - - - - {translate('limitOrder.confirmInfo')} - - - + ) - }, [ - buyAsset?.symbol, - feeAsset?.symbol, - limitPrice?.buyAssetDenomination, - networkFeeCryptoPrecision, - networkFeeUserCurrency, - quoteExpirationTimestamp, - sellAsset?.symbol, - translate, - ]) + }, [buyAmountCryptoBaseUnit, buyAsset, sellAmountCryptoBaseUnit, sellAsset]) + + const detail = useMemo(() => { + return + }, []) const button = useMemo(() => { return ( @@ -145,6 +76,7 @@ export const LimitOrderConfirm = () => { return }, [detail, button]) + if (!body) return null return ( { + const translate = useTranslate() + + const sellAsset = useAppSelector(selectActiveQuoteSellAsset) + const buyAsset = useAppSelector(selectActiveQuoteBuyAsset) + const feeAsset = useAppSelector(selectActiveQuoteFeeAsset) + const networkFeeCryptoPrecision = useAppSelector(selectActiveQuoteNetworkFeeCryptoPrecision) + const networkFeeUserCurrency = useAppSelector(selectActiveQuoteNetworkFeeUserCurrency) + const limitPrice = useAppSelector(selectActiveQuoteLimitPrice) + const quoteExpirationTimestamp = useAppSelector(selectActiveQuoteExpirationTimestamp) + + return ( + + + + + + + + + = + + + + + + + + + + + + {SwapperName.CowSwap} + + + + + + + + + + + + + + + + + + + + + + + + + + {translate('limitOrder.confirmInfo')} + + + + ) +} diff --git a/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx b/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx index 5905d237760..7f042140695 100644 --- a/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx +++ b/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx @@ -18,6 +18,7 @@ import { useAccountsFetchQuery } from 'context/AppProvider/hooks/useAccountsFetc import { WalletActions } from 'context/WalletProvider/actions' import { useActions } from 'hooks/useActions' import { useErrorToast } from 'hooks/useErrorToast/useErrorToast' +import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' import { useWallet } from 'hooks/useWallet/useWallet' import { getErc20Allowance } from 'lib/utils/evm' import { useQuoteLimitOrderQuery } from 'state/apis/limit-orders/limitOrderApi' @@ -123,6 +124,7 @@ export const LimitOrderInput = ({ } = useActions(limitOrderInput.actions) const { setActiveQuote } = useActions(limitOrderSlice.actions) const { isFetching: isAccountsMetadataLoading } = useAccountsFetchQuery() + const isNewLimitFlowEnabled = useFeatureFlag('NewLimitFlow') const feeParams = useMemo( () => ({ feeModel: 'SWAPPER' as const, inputAmountUsd: inputSellAmountUsd }), @@ -277,6 +279,12 @@ export const LimitOrderInput = ({ const { assetReference, chainId } = fromAssetId(limitOrderQuoteParams.sellAssetId) + // If the new limit flow is enabled, we don't need to check the allowance at this step + if (isNewLimitFlowEnabled) { + history.push(LimitOrderRoutePaths.Confirm) + return + } + // Trigger loading state while we check the allowance setIsCheckingAllowance(true) @@ -312,6 +320,7 @@ export const LimitOrderInput = ({ setActiveQuote, expiry, buyAmountCryptoBaseUnit, + isNewLimitFlowEnabled, handleConnect, history, showErrorToast, diff --git a/src/state/apis/limit-orders/limitOrderApi.ts b/src/state/apis/limit-orders/limitOrderApi.ts index 72b827e4518..766b481b288 100644 --- a/src/state/apis/limit-orders/limitOrderApi.ts +++ b/src/state/apis/limit-orders/limitOrderApi.ts @@ -134,7 +134,7 @@ export const limitOrderApi = createApi({ const { unsignedOrderCreation, params: { sellAssetId, accountId }, - } = selectConfirmedLimitOrder(state, quoteId) + } = selectConfirmedLimitOrder(state, { quoteId }) const { chainId } = fromAssetId(sellAssetId) const accountMetadata = selectPortfolioAccountMetadataByAccountId(state, { accountId }) diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 557b6f81562..a1a73d74795 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -4,7 +4,7 @@ import type { QueryStatus } from '@reduxjs/toolkit/dist/query' import type { AccountId, AssetId, ChainId } from '@shapeshiftoss/caip' import type { TxMetadata } from '@shapeshiftoss/chain-adapters' import type { TradeQuote } from '@shapeshiftoss/swapper' -import type { HistoryTimeframe } from '@shapeshiftoss/types' +import type { HistoryTimeframe, QuoteId } from '@shapeshiftoss/types' import type { TxStatus } from '@shapeshiftoss/unchained-client' import createCachedSelector from 're-reselect' import type { FiatRampAction } from 'components/Modals/FiatRamps/FiatRampsCommon' @@ -57,6 +57,7 @@ type ParamFilter = Partial<{ parser: TxMetadata['parser'] hopIndex: number tradeId: TradeQuote['id'] + quoteId: QuoteId }> type ParamFilterKey = keyof ParamFilter @@ -103,3 +104,4 @@ export const selectParserParamFromFilter = selectParamFromFilter('parser') export const selectHopIndexParamFromRequiredFilter = selectRequiredParamFromFilter('hopIndex') export const selectTradeIdParamFromRequiredFilter = selectRequiredParamFromFilter('tradeId') +export const selectQuoteIdParamFromRequiredFilter = selectRequiredParamFromFilter('quoteId') diff --git a/src/state/slices/limitOrderSlice/selectors.ts b/src/state/slices/limitOrderSlice/selectors.ts index bba4dbb99ac..20ec72432fd 100644 --- a/src/state/slices/limitOrderSlice/selectors.ts +++ b/src/state/slices/limitOrderSlice/selectors.ts @@ -2,6 +2,8 @@ import type { QuoteId } from '@shapeshiftoss/types' import { bn, bnOrZero, fromBaseUnit } from '@shapeshiftoss/utils' import { createSelector } from 'reselect' import type { ReduxState } from 'state/reducer' +import { createDeepEqualOutputSelector } from 'state/selector-utils' +import { selectQuoteIdParamFromRequiredFilter } from 'state/selectors' import { PriceDirection } from '../limitOrderInputSlice/constants' import { @@ -20,6 +22,11 @@ export const selectActiveQuote = createSelector( limitOrderSlice => limitOrderSlice.activeQuote, ) +export const selectActiveQuoteId = createSelector( + selectActiveQuote, + activeQuote => activeQuote?.response.id, +) + export const selectActiveQuoteExpirationTimestamp = createSelector( selectActiveQuote, activeQuote => { @@ -167,7 +174,15 @@ export const selectActiveQuoteLimitPrice = createSelector( export const selectConfirmedLimitOrder = createSelector( selectLimitOrderSlice, - (_state: ReduxState, quoteId: QuoteId) => quoteId, + selectQuoteIdParamFromRequiredFilter, (limitOrderSlice: LimitOrderState, quoteId: QuoteId) => limitOrderSlice.confirmedLimitOrder[quoteId], ) + +export const selectLimitOrderSubmissionMetadata = createDeepEqualOutputSelector( + selectLimitOrderSlice, + selectQuoteIdParamFromRequiredFilter, + (limitOrders, quoteId) => { + return limitOrders.orderSubmission[quoteId] + }, +)