diff --git a/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderConfig.tsx b/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderConfig.tsx index 619fac02dfb..9e24401d1c2 100644 --- a/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderConfig.tsx +++ b/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderConfig.tsx @@ -8,11 +8,12 @@ import { MenuItemOption, MenuList, MenuOptionGroup, + Skeleton, Stack, } from '@chakra-ui/react' import type { Asset } from '@shapeshiftoss/types' import { bn, bnOrZero } from '@shapeshiftoss/utils' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { NumberFormatValues } from 'react-number-format' import NumberFormat from 'react-number-format' import { StyledAssetMenuButton } from 'components/AssetSelection/components/AssetMenuButton' @@ -77,22 +78,25 @@ const getExpiryOptionTranslation = (expiryOption: ExpiryOption) => { const timePeriodRightIcon = const swapIcon = -const swapPriceButtonProps = { pr: 4 } +const disabledProps = { opacity: 0.5, cursor: 'not-allowed', userSelect: 'none' } +const swapPriceButtonProps = { pr: 4, _disabled: disabledProps } type LimitOrderConfigProps = { sellAsset: Asset buyAsset: Asset - marketPriceBuyAssetCryptoPrecision: string - limitPriceBuyAssetCryptoPrecision: string - setLimitPriceBuyAssetCryptoPrecision: (priceBuyAssetCryptoPrecision: string) => void + isLoading: boolean + marketPriceBuyAsset: string + limitPriceBuyAsset: string + setLimitPriceBuyAsset: (newLimitPriceBuyAsset: string) => void } export const LimitOrderConfig = ({ sellAsset, buyAsset, - marketPriceBuyAssetCryptoPrecision, - limitPriceBuyAssetCryptoPrecision, - setLimitPriceBuyAssetCryptoPrecision, + isLoading, + marketPriceBuyAsset, + limitPriceBuyAsset, + setLimitPriceBuyAsset, }: LimitOrderConfigProps) => { const priceAmountRef = useRef(null) @@ -100,6 +104,20 @@ export const LimitOrderConfig = ({ const [presetLimit, setPresetLimit] = useState(PresetLimit.Market) const [expiryOption, setExpiryOption] = useState(ExpiryOption.SevenDays) + // Reset the user config when the assets change + useEffect( + () => { + setPriceDirection(PriceDirection.Default) + setPresetLimit(PresetLimit.Market) + setExpiryOption(ExpiryOption.SevenDays) + setLimitPriceBuyAsset(marketPriceBuyAsset) + }, + // NOTE: we DO NOT want to react to `marketPriceBuyAsset` here, because polling will reset it + // every time! + // eslint-disable-next-line react-hooks/exhaustive-deps + [sellAsset, buyAsset, setLimitPriceBuyAsset], + ) + const { number: { localeParts }, } = useLocaleFormatter() @@ -120,14 +138,14 @@ export const LimitOrderConfig = ({ // Lower the decimal places when the integer is greater than 8 significant digits for better UI const priceCryptoFormatted = useMemo(() => { - const cryptoAmountIntegerCount = bnOrZero( - bnOrZero(limitPriceBuyAssetCryptoPrecision).toFixed(0), - ).precision(true) + const cryptoAmountIntegerCount = bnOrZero(bnOrZero(limitPriceBuyAsset).toFixed(0)).precision( + true, + ) return cryptoAmountIntegerCount <= 8 - ? limitPriceBuyAssetCryptoPrecision - : bnOrZero(limitPriceBuyAssetCryptoPrecision).toFixed(3) - }, [limitPriceBuyAssetCryptoPrecision]) + ? limitPriceBuyAsset + : bnOrZero(limitPriceBuyAsset).toFixed(3) + }, [limitPriceBuyAsset]) const arrow = useMemo(() => { return priceDirection === PriceDirection.Default ? '↑' : '↓' @@ -152,14 +170,14 @@ export const LimitOrderConfig = ({ assertUnreachable(presetLimit) } })() - const adjustedLimitPrice = bn(marketPriceBuyAssetCryptoPrecision).times(multiplier).toFixed() + const adjustedLimitPrice = bn(marketPriceBuyAsset).times(multiplier).toFixed() const maybeReversedPrice = priceDirection === PriceDirection.Reversed ? bn(1).div(adjustedLimitPrice).toFixed() : adjustedLimitPrice - setLimitPriceBuyAssetCryptoPrecision(maybeReversedPrice) + setLimitPriceBuyAsset(maybeReversedPrice) }, - [marketPriceBuyAssetCryptoPrecision, setLimitPriceBuyAssetCryptoPrecision], + [marketPriceBuyAsset, setLimitPriceBuyAsset], ) const handleSetMarketLimit = useCallback(() => { @@ -191,37 +209,31 @@ export const LimitOrderConfig = ({ if (isCustomLimit) { // For custom limit, just take the reciprocal as we don't know what the original input value was - setLimitPriceBuyAssetCryptoPrecision(bn(1).div(limitPriceBuyAssetCryptoPrecision).toFixed()) + setLimitPriceBuyAsset(bn(1).div(limitPriceBuyAsset).toFixed()) } else { // Otherwise set it to the precise value based on the original market price handleSetPresetLimit(presetLimit, newPriceDirection) } - }, [ - handleSetPresetLimit, - limitPriceBuyAssetCryptoPrecision, - presetLimit, - priceDirection, - setLimitPriceBuyAssetCryptoPrecision, - ]) + }, [handleSetPresetLimit, limitPriceBuyAsset, presetLimit, priceDirection, setLimitPriceBuyAsset]) const handlePriceChange = useCallback(() => { // onChange will send us the formatted value // To get around this we need to get the value from the onChange using a ref // Now when the max buttons are clicked the onChange will not fire - setLimitPriceBuyAssetCryptoPrecision(priceAmountRef.current ?? '0') + setLimitPriceBuyAsset(priceAmountRef.current ?? '0') // Unset the preset limit, as this is a custom value setPresetLimit(undefined) - }, [setLimitPriceBuyAssetCryptoPrecision]) + }, [setLimitPriceBuyAsset]) const handleValueChange = useCallback( (values: NumberFormatValues) => { // This fires anytime value changes including setting it on max click // Store the value in a ref to send when we actually want the onChange to fire priceAmountRef.current = values.value - setLimitPriceBuyAssetCryptoPrecision(values.value) + setLimitPriceBuyAsset(values.value) }, - [setLimitPriceBuyAssetCryptoPrecision], + [setLimitPriceBuyAsset], ) const expiryOptionTranslation = useMemo(() => { @@ -239,7 +251,7 @@ export const LimitOrderConfig = ({ - + @@ -255,23 +267,26 @@ export const LimitOrderConfig = ({ - + + + @@ -280,6 +295,7 @@ export const LimitOrderConfig = ({ size='sm' isActive={presetLimit === PresetLimit.Market} onClick={handleSetMarketLimit} + isDisabled={isLoading} > Market @@ -288,6 +304,7 @@ export const LimitOrderConfig = ({ size='sm' isActive={presetLimit === PresetLimit.OnePercent} onClick={handleSetOnePercentLimit} + isDisabled={isLoading} > 1% {arrow} @@ -296,6 +313,7 @@ export const LimitOrderConfig = ({ size='sm' isActive={presetLimit === PresetLimit.TwoPercent} onClick={handleSetTwoPercentLimit} + isDisabled={isLoading} > 2% {arrow} @@ -304,6 +322,7 @@ export const LimitOrderConfig = ({ size='sm' isActive={presetLimit === PresetLimit.FivePercent} onClick={handleSetFivePercentLimit} + isDisabled={isLoading} > 5% {arrow} @@ -312,6 +331,7 @@ export const LimitOrderConfig = ({ size='sm' isActive={presetLimit === PresetLimit.TenPercent} onClick={handleSetTenPercentLimit} + isDisabled={isLoading} > 10% {arrow} diff --git a/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx b/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx index 55ae21a67e3..52916d80f70 100644 --- a/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx +++ b/src/components/MultiHopTrade/components/LimitOrder/components/LimitOrderInput.tsx @@ -1,9 +1,9 @@ -import { Divider, Stack, useMediaQuery } from '@chakra-ui/react' +import { Divider, Stack } from '@chakra-ui/react' import { skipToken } from '@reduxjs/toolkit/query' import { foxAssetId, fromAccountId, usdcAssetId } from '@shapeshiftoss/caip' import { SwapperName } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' -import { bnOrZero, toBaseUnit } from '@shapeshiftoss/utils' +import { BigNumber, bn, bnOrZero, fromBaseUnit, toBaseUnit } from '@shapeshiftoss/utils' import type { FormEvent } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useFormContext } from 'react-hook-form' @@ -15,6 +15,7 @@ import { WalletActions } from 'context/WalletProvider/actions' import { useErrorHandler } from 'hooks/useErrorToast/useErrorToast' import { useWallet } from 'hooks/useWallet/useWallet' import { localAssetData } from 'lib/asset-service' +import { calculateFees } from 'lib/fees/model' import type { ParameterModel } from 'lib/fees/parameters/types' import { useQuoteLimitOrderQuery } from 'state/apis/limit-orders/limitOrderApi' import { selectIsSnapshotApiQueriesPending, selectVotingPower } from 'state/apis/snapshot/selectors' @@ -23,16 +24,18 @@ import { selectFirstAccountIdByChainId, selectIsAnyAccountMetadataLoadedForChainId, selectMarketDataByAssetIdUserCurrency, + selectUsdRateByAssetId, + selectUserCurrencyToUsdRate, } from 'state/slices/selectors' import { - selectActiveQuote, - // selectBuyAmountAfterFeesUserCurrency, + selectCalculatedFees, + selectDefaultSlippagePercentage, selectIsTradeQuoteRequestAborted, selectShouldShowTradeQuoteOrAwaitInput, } from 'state/slices/tradeQuoteSlice/selectors' import { useAppSelector } from 'state/store' -import { breakpoints } from 'theme/theme' +import { SharedSlippagePopover } from '../../SharedTradeInput/SharedSlippagePopover' import { SharedTradeInput } from '../../SharedTradeInput/SharedTradeInput' import { SharedTradeInputBody } from '../../SharedTradeInput/SharedTradeInputBody' import { SharedTradeInputFooter } from '../../SharedTradeInput/SharedTradeInputFooter/SharedTradeInputFooter' @@ -43,6 +46,7 @@ import { LimitOrderBuyAsset } from './LimitOrderBuyAsset' import { LimitOrderConfig } from './LimitOrderConfig' const votingPowerParams: { feeModel: ParameterModel } = { feeModel: 'SWAPPER' } +const thorVotingPowerParams: { feeModel: ParameterModel } = { feeModel: 'THORSWAP' } type LimitOrderInputProps = { tradeInputRef: React.MutableRefObject @@ -63,14 +67,18 @@ export const LimitOrderInput = ({ const history = useHistory() const { handleSubmit } = useFormContext() const { showErrorToast } = useErrorHandler() - const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false }) + const [userSlippagePercentage, setUserSlippagePercentage] = useState() const [sellAsset, setSellAsset] = useState(localAssetData[usdcAssetId] ?? defaultAsset) const [buyAsset, setBuyAsset] = useState(localAssetData[foxAssetId] ?? defaultAsset) + const [limitPriceBuyAsset, setLimitPriceBuyAsset] = useState('0') const defaultAccountId = useAppSelector(state => selectFirstAccountIdByChainId(state, sellAsset.chainId), ) + const defaultSlippagePercentage = useAppSelector(state => + selectDefaultSlippagePercentage(state, SwapperName.CowSwap), + ) const [buyAccountId, setBuyAccountId] = useState(defaultAccountId) const [sellAccountId, setSellAccountId] = useState(defaultAccountId) @@ -86,13 +94,14 @@ export const LimitOrderInput = ({ const [isConfirmationLoading, setIsConfirmationLoading] = useState(false) const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false) const [sellAmountCryptoPrecision, setSellAmountCryptoPrecision] = useState('0') - // const buyAmountAfterFeesUserCurrency = useAppSelector(selectBuyAmountAfterFeesUserCurrency) const shouldShowTradeQuoteOrAwaitInput = useAppSelector(selectShouldShowTradeQuoteOrAwaitInput) const isSnapshotApiQueriesPending = useAppSelector(selectIsSnapshotApiQueriesPending) const isTradeQuoteRequestAborted = useAppSelector(selectIsTradeQuoteRequestAborted) const hasUserEnteredAmount = true const votingPower = useAppSelector(state => selectVotingPower(state, votingPowerParams)) - const activeQuote = useAppSelector(selectActiveQuote) + const thorVotingPower = useAppSelector(state => selectVotingPower(state, thorVotingPowerParams)) + const sellAssetUsdRate = useAppSelector(state => selectUsdRateByAssetId(state, sellAsset.assetId)) + const userCurrencyRate = useAppSelector(selectUserCurrencyToUsdRate) const isAnyAccountMetadataLoadedForChainIdFilter = useMemo( () => ({ chainId: sellAsset.chainId }), [sellAsset.chainId], @@ -101,53 +110,32 @@ export const LimitOrderInput = ({ selectIsAnyAccountMetadataLoadedForChainId(state, isAnyAccountMetadataLoadedForChainIdFilter), ) - const handleOpenCompactQuoteList = useCallback(() => { - if (!isCompact && !isSmallerThanXl) return - history.push({ pathname: LimitOrderRoutePaths.QuoteList }) - }, [history, isCompact, isSmallerThanXl]) - const isVotingPowerLoading = useMemo( () => isSnapshotApiQueriesPending && votingPower === undefined, [isSnapshotApiQueriesPending, votingPower], ) - const isLoading = useMemo( - () => - // No account meta loaded for that chain - !isAnyAccountMetadataLoadedForChainId || - (!shouldShowTradeQuoteOrAwaitInput && !isTradeQuoteRequestAborted) || - isConfirmationLoading || - // Only consider snapshot API queries as pending if we don't have voting power yet - // if we do, it means we have persisted or cached (both stale) data, which is enough to let the user continue - // as we are optimistic and don't want to be waiting for a potentially very long time for the snapshot API to respond - isVotingPowerLoading, - [ - isAnyAccountMetadataLoadedForChainId, - shouldShowTradeQuoteOrAwaitInput, - isTradeQuoteRequestAborted, - isConfirmationLoading, - isVotingPowerLoading, - ], - ) - - const assetMarketDataUserCurrency = useAppSelector(state => + const sellAssetMarketDataUserCurrency = useAppSelector(state => selectMarketDataByAssetIdUserCurrency(state, sellAsset.assetId), ) const sellAmountUserCurrency = useMemo(() => { - return bnOrZero(sellAmountCryptoPrecision).times(assetMarketDataUserCurrency.price).toFixed() - }, [assetMarketDataUserCurrency.price, sellAmountCryptoPrecision]) + return bnOrZero(sellAmountCryptoPrecision) + .times(sellAssetMarketDataUserCurrency.price) + .toFixed() + }, [sellAssetMarketDataUserCurrency.price, sellAmountCryptoPrecision]) + + const sellAmountUsd = useMemo(() => { + return bnOrZero(sellAmountCryptoPrecision) + .times(sellAssetUsdRate ?? '0') + .toFixed() + }, [sellAmountCryptoPrecision, sellAssetUsdRate]) const warningAcknowledgementMessage = useMemo(() => { // TODO: Implement me return '' }, []) - const headerRightContent = useMemo(() => { - // TODO: Implement me - return <> - }, []) - const handleSwitchAssets = useCallback(() => { setSellAsset(buyAsset) setBuyAsset(sellAsset) @@ -221,6 +209,19 @@ export const LimitOrderInput = ({ return fromAccountId(sellAccountId).account as Address }, [sellAccountId]) + const affiliateBps = useMemo(() => { + const tradeAmountUsd = bnOrZero(sellAssetUsdRate).times(sellAmountCryptoPrecision) + + const { feeBps } = calculateFees({ + tradeAmountUsd, + foxHeld: bnOrZero(votingPower), + thorHeld: bnOrZero(thorVotingPower), + feeModel: 'SWAPPER', + }) + + return feeBps.toFixed(0) + }, [sellAmountCryptoPrecision, sellAssetUsdRate, thorVotingPower, votingPower]) + const limitOrderQuoteParams = useMemo(() => { // Return skipToken if any required params are missing if (bnOrZero(sellAmountCryptoBaseUnit).isZero()) { @@ -231,40 +232,81 @@ export const LimitOrderInput = ({ sellAssetId: sellAsset.assetId, buyAssetId: buyAsset.assetId, chainId: sellAsset.chainId, - slippageTolerancePercentageDecimal: '0', // TODO: wire this up! - affiliateBps: '0', // TODO: wire this up! + slippageTolerancePercentageDecimal: bn(userSlippagePercentage ?? defaultSlippagePercentage) + .div(100) + .toString(), + affiliateBps, sellAccountAddress, sellAmountCryptoBaseUnit, recipientAddress, } }, [ - buyAsset.assetId, - sellAccountAddress, sellAmountCryptoBaseUnit, sellAsset.assetId, sellAsset.chainId, + buyAsset.assetId, + userSlippagePercentage, + defaultSlippagePercentage, + affiliateBps, + sellAccountAddress, recipientAddress, ]) - const { data, error } = useQuoteLimitOrderQuery(limitOrderQuoteParams) - + // This fetches the quote only, not the limit order. The quote is used to determine the market + // price. When submitting a limit order, the buyAmount is (optionally) modified based on the user + // input, and then re-attached to the `LimitOrder` before signing and submitting via our + // `placeLimitOrder` endpoint in limitOrderApi + const { + data, + error, + isFetching: isLimitOrderQuoteFetching, + } = useQuoteLimitOrderQuery(limitOrderQuoteParams) + + const marketPriceBuyAsset = useMemo(() => { + if (!data) return '0' + return bnOrZero(fromBaseUnit(data.quote.buyAmount, buyAsset.precision)) + .div(fromBaseUnit(data.quote.sellAmount, sellAsset.precision)) + .toFixed() + }, [buyAsset.precision, data, sellAsset.precision]) + + // Reset the limit price when the market price changes. + // TODO: If we introduce polling of quotes, we will need to add logic inside `LimitOrderConfig` to + // not reset the user's config unless the asset pair changes. useEffect(() => { - /* - This is the quote only, not the limit order. The quote is used to determine the market price. - When submitting a limit order, the buyAmount is (optionally) modified based on the user input, - and then re-attached to the `LimitOrder` before signing and submitting via our - `placeLimitOrder` endpoint in limitOrderApi - */ - console.log('limit order quote response:', data) - console.log('limit order quote error:', error) - }, [data, error]) - - const marketPriceBuyAssetCryptoPrecision = '123423' - - // TODO: debounce this with `useDebounce` when including in the query - const [limitPriceBuyAssetCryptoPrecision, setLimitPriceBuyAssetCryptoPrecision] = useState( - marketPriceBuyAssetCryptoPrecision, - ) + setLimitPriceBuyAsset(marketPriceBuyAsset) + }, [marketPriceBuyAsset]) + + const isLoading = useMemo(() => { + return ( + isLimitOrderQuoteFetching || + // No account meta loaded for that chain + !isAnyAccountMetadataLoadedForChainId || + (!shouldShowTradeQuoteOrAwaitInput && !isTradeQuoteRequestAborted) || + isConfirmationLoading || + // Only consider snapshot API queries as pending if we don't have voting power yet + // if we do, it means we have persisted or cached (both stale) data, which is enough to let the user continue + // as we are optimistic and don't want to be waiting for a potentially very long time for the snapshot API to respond + isVotingPowerLoading + ) + }, [ + isAnyAccountMetadataLoadedForChainId, + isConfirmationLoading, + isLimitOrderQuoteFetching, + isTradeQuoteRequestAborted, + isVotingPowerLoading, + shouldShowTradeQuoteOrAwaitInput, + ]) + + const headerRightContent = useMemo(() => { + return ( + + ) + }, [defaultSlippagePercentage, userSlippagePercentage]) const bodyContent = useMemo(() => { return ( @@ -294,64 +336,73 @@ export const LimitOrderInput = ({ ) }, [ + buyAccountId, buyAsset, isInputtingFiatSellAmount, isLoading, + limitPriceBuyAsset, + marketPriceBuyAsset, + sellAccountId, sellAmountCryptoPrecision, sellAmountUserCurrency, sellAsset, - sellAccountId, - handleSwitchAssets, - handleSetSellAsset, - setSellAccountId, - buyAccountId, - setBuyAccountId, handleSetBuyAsset, - limitPriceBuyAssetCryptoPrecision, + handleSetSellAsset, + handleSwitchAssets, ]) + const { feeUsd } = useAppSelector(state => + selectCalculatedFees(state, { feeModel: 'SWAPPER', inputAmountUsd: sellAmountUsd }), + ) + + const affiliateFeeAfterDiscountUserCurrency = useMemo(() => { + return bn(feeUsd).times(userCurrencyRate).toFixed(2, BigNumber.ROUND_HALF_UP) + }, [feeUsd, userCurrencyRate]) + const footerContent = useMemo(() => { return ( {renderedRecipientAddress} ) }, [ + affiliateBps, + affiliateFeeAfterDiscountUserCurrency, buyAsset, - handleOpenCompactQuoteList, hasUserEnteredAmount, - isCompact, + sellAmountUsd, + error, isLoading, - activeQuote?.rate, - sellAsset, + limitPriceBuyAsset, sellAccountId, isRecipientAddressEntryActive, + sellAsset, renderedRecipientAddress, ]) diff --git a/src/components/MultiHopTrade/components/RateGasRow.tsx b/src/components/MultiHopTrade/components/RateGasRow.tsx index 60af29ec9a9..f1297441ec7 100644 --- a/src/components/MultiHopTrade/components/RateGasRow.tsx +++ b/src/components/MultiHopTrade/components/RateGasRow.tsx @@ -1,46 +1,28 @@ import { ArrowUpDownIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons' import type { FlexProps } from '@chakra-ui/react' -import { - Box, - Center, - Collapse, - Flex, - Skeleton, - Stack, - Tooltip, - useDisclosure, -} from '@chakra-ui/react' +import { Box, Collapse, Flex, Skeleton, Stack, Tooltip, useDisclosure } from '@chakra-ui/react' import type { SwapperName, SwapSource } from '@shapeshiftoss/swapper' -import { - THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, - THORCHAIN_STREAM_SWAP_SOURCE, -} from '@shapeshiftoss/swapper/dist/swappers/ThorchainSwapper/constants' -import { AnimatePresence } from 'framer-motion' import type { PropsWithChildren } from 'react' -import { type FC, memo, useMemo } from 'react' +import { type FC, memo } from 'react' import { FaGasPump } from 'react-icons/fa' import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' import { HelperTooltip } from 'components/HelperTooltip/HelperTooltip' -import { StreamIcon } from 'components/Icons/Stream' import { Row } from 'components/Row/Row' -import { SlideTransitionX } from 'components/SlideTransitionX' import { Text } from 'components/Text' -import { bnOrZero } from 'lib/bignumber/bignumber' -import { firstNonZeroDecimal } from 'lib/math' -import { SwapperIcon } from './TradeInput/components/SwapperIcon/SwapperIcon' +import { SwapperIcons } from './SwapperIcons' type RateGasRowProps = { - sellSymbol?: string - buySymbol?: string - rate?: string - gasFee: string + buyAssetSymbol: string + isDisabled?: boolean isLoading?: boolean - allowSelectQuote: boolean - swapperName?: SwapperName - swapSource?: SwapSource - onRateClick?: () => void + rate: string | undefined + sellAssetSymbol: string + swapperName: SwapperName | undefined + swapSource: SwapSource | undefined + totalNetworkFeeFiatPrecision: string | undefined + onClick?: () => void } & PropsWithChildren const helpersTooltipFlexProps: FlexProps = { flexDirection: 'row-reverse' } @@ -52,56 +34,20 @@ const rateHover = { export const RateGasRow: FC = memo( ({ - sellSymbol, - buySymbol, - rate, - gasFee, + buyAssetSymbol, + children, + isDisabled, isLoading, - allowSelectQuote, + rate, + sellAssetSymbol, swapperName, swapSource, - onRateClick, - children, + totalNetworkFeeFiatPrecision, + onClick, }) => { const translate = useTranslate() const { isOpen, onToggle } = useDisclosure() - const swapperIcons = useMemo(() => { - const isStreaming = - swapSource === THORCHAIN_STREAM_SWAP_SOURCE || - swapSource === THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE - return ( - - {isStreaming && ( - -
- -
-
- )} -
- {swapperName && } -
-
- ) - }, [swapSource, swapperName]) - switch (true) { case isLoading: return ( @@ -140,10 +86,7 @@ export const RateGasRow: FC = memo( fontSize='sm' > - + = memo( display='flex' alignItems='center' gap={2} - _hover={allowSelectQuote ? rateHover : undefined} - onClick={onRateClick} + _hover={!isDisabled ? rateHover : undefined} + onClick={onClick} > - {swapperIcons} + = memo( - - {allowSelectQuote && } + + {!isDisabled && } @@ -188,7 +127,7 @@ export const RateGasRow: FC = memo( - + {isOpen ? ( diff --git a/src/components/MultiHopTrade/components/SharedTradeInput/SharedSlippagePopover.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedSlippagePopover.tsx new file mode 100644 index 00000000000..a2e0d042ddb --- /dev/null +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedSlippagePopover.tsx @@ -0,0 +1,212 @@ +import { + Alert, + AlertDescription, + AlertIcon, + Box, + Button, + ButtonGroup, + FormControl, + IconButton, + Input, + InputGroup, + InputRightElement, + Popover, + PopoverBody, + PopoverContent, + PopoverTrigger, + Tooltip, +} from '@chakra-ui/react' +import { bnOrZero } from '@shapeshiftoss/chain-adapters' +import type { FC } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FaGear } from 'react-icons/fa6' +import { useTranslate } from 'react-polyglot' +import { HelperTooltip } from 'components/HelperTooltip/HelperTooltip' +import { Row } from 'components/Row/Row' +import { Text } from 'components/Text' +import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' + +enum SlippageType { + Auto = 'Auto', + Custom = 'Custom', +} + +const maxSlippagePercentage = '30' + +const focusStyle = { '&[aria-invalid=true]': { borderColor: 'red.500' } } + +const faGear = + +type SharedSlippagePopoverProps = { + defaultSlippagePercentage: string + isDisabled?: boolean + quoteSlippagePercentage: string | undefined + tooltipTranslation?: string + userSlippagePercentage: string | undefined + setUserSlippagePercentage: (slippagePercentage: string | undefined) => void +} + +export const SharedSlippagePopover: FC = memo( + ({ + defaultSlippagePercentage, + isDisabled, + quoteSlippagePercentage, + tooltipTranslation, + userSlippagePercentage, + setUserSlippagePercentage, + }) => { + const [slippageType, setSlippageType] = useState(SlippageType.Auto) + const [slippageAmount, setSlippageAmount] = useState( + defaultSlippagePercentage, + ) + const [isInvalid, setIsInvalid] = useState(false) + const translate = useTranslate() + const inputRef = useRef(null) + const isAdvancedSlippageEnabled = useFeatureFlag('AdvancedSlippage') + + useEffect(() => { + // Handles re-opening the slippage popover and/or going back to input step + if (userSlippagePercentage) { + setSlippageType(SlippageType.Custom) + setSlippageAmount(userSlippagePercentage) + } else { + setSlippageType(SlippageType.Auto) + setSlippageAmount(quoteSlippagePercentage ?? defaultSlippagePercentage) + } + // We only want this to run on mount, though not to be reactive to userSlippagePercentage + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultSlippagePercentage, quoteSlippagePercentage]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value + if (bnOrZero(value).gt(maxSlippagePercentage)) { + setIsInvalid(true) + } else { + setIsInvalid(false) + } + setSlippageAmount(value) + setSlippageType(SlippageType.Custom) + }, []) + + const handleClose = useCallback(() => { + if (slippageType === SlippageType.Custom && !isInvalid) + setUserSlippagePercentage(slippageAmount) + else if (slippageType === SlippageType.Auto) setUserSlippagePercentage(undefined) + }, [setUserSlippagePercentage, isInvalid, slippageAmount, slippageType]) + + const handleSlippageTypeChange = useCallback( + (type: SlippageType) => { + if (type === SlippageType.Auto) { + setSlippageAmount(defaultSlippagePercentage) + setIsInvalid(false) + } else { + inputRef && inputRef.current && inputRef.current.focus() + setSlippageAmount(slippageAmount) + } + setSlippageType(type) + }, + [defaultSlippagePercentage, slippageAmount], + ) + + const handleAutoSlippageTypeChange = useCallback( + () => handleSlippageTypeChange(SlippageType.Auto), + [handleSlippageTypeChange], + ) + + const handleCustomSlippageTypeChange = useCallback( + () => handleSlippageTypeChange(SlippageType.Custom), + [handleSlippageTypeChange], + ) + + const isHighSlippage = useMemo(() => bnOrZero(slippageAmount).gt(1), [slippageAmount]) + const isLowSlippage = useMemo(() => bnOrZero(slippageAmount).lt(0.05), [slippageAmount]) + + if (!isAdvancedSlippageEnabled) return null + + return ( + + + + + + + + + + + + + + + + + {slippageType === SlippageType.Auto && 'Auto'} + + + + + + + + + + + + + % + + + + + {isHighSlippage && ( + + + + {translate('trade.slippage.warning')} + + + )} + {isLowSlippage && ( + + + + {translate('trade.slippage.lowSlippage')} + + + )} + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputFooter/SharedTradeInputFooter.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputFooter/SharedTradeInputFooter.tsx index 385cd8542cc..0a713e17d96 100644 --- a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputFooter/SharedTradeInputFooter.tsx +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputFooter/SharedTradeInputFooter.tsx @@ -1,4 +1,4 @@ -import { CardFooter, useMediaQuery } from '@chakra-ui/react' +import { CardFooter } from '@chakra-ui/react' import type { SwapperName, SwapSource } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' import type { InterpolationOptions } from 'node-polyglot' @@ -9,7 +9,6 @@ import { Text } from 'components/Text' import { useAccountsFetchQuery } from 'context/AppProvider/hooks/useAccountsFetchQuery' import { selectFeeAssetById } from 'state/slices/selectors' import { useAppSelector } from 'state/store' -import { breakpoints } from 'theme/theme' import { ReceiveSummary } from './components/ReceiveSummary' @@ -20,19 +19,19 @@ type SharedTradeInputFooterProps = { children?: JSX.Element hasUserEnteredAmount: boolean inputAmountUsd: string | undefined - isCompact: boolean | undefined isError: boolean isLoading: boolean quoteStatusTranslation: string | [string, InterpolationOptions] rate: string | undefined - sellAsset: Asset + receiveSummaryDetails?: JSX.Element | null sellAccountId: string | undefined + sellAsset: Asset + shouldDisableGasRateRowClick?: boolean shouldDisablePreviewButton: boolean | undefined swapperName: SwapperName | undefined swapSource: SwapSource | undefined - totalNetworkFeeFiatPrecision: string - receiveSummaryDetails?: JSX.Element | null - onRateClick: () => void + totalNetworkFeeFiatPrecision: string | undefined + onGasRateRowClick?: () => void } export const SharedTradeInputFooter = ({ @@ -42,22 +41,20 @@ export const SharedTradeInputFooter = ({ children, hasUserEnteredAmount, inputAmountUsd, - isCompact, isError, isLoading: isParentLoading, quoteStatusTranslation, rate, - sellAsset, + receiveSummaryDetails, sellAccountId, + sellAsset, + shouldDisableGasRateRowClick, shouldDisablePreviewButton: parentShouldDisablePreviewButton, swapperName, swapSource, totalNetworkFeeFiatPrecision, - receiveSummaryDetails, - onRateClick, + onGasRateRowClick, }: SharedTradeInputFooterProps) => { - const [isSmallerThanXl] = useMediaQuery(`(max-width: ${breakpoints.xl})`, { ssr: false }) - const buyAssetFeeAsset = useAppSelector(state => selectFeeAssetById(state, buyAsset?.assetId ?? ''), ) @@ -101,15 +98,15 @@ export const SharedTradeInputFooter = ({ > {hasUserEnteredAmount && ( +import { SharedSlippagePopover } from './SharedTradeInput/SharedSlippagePopover' type SlippagePopoverProps = { isDisabled?: boolean @@ -51,165 +17,27 @@ type SlippagePopoverProps = { export const SlippagePopover: FC = memo( ({ tooltipTranslation, isDisabled }) => { + const dispatch = useAppDispatch() const defaultSlippagePercentage = useAppSelector(selectDefaultSlippagePercentage) const quoteSlippagePercentage = useAppSelector(selectQuoteSlippageTolerancePercentage) - const userSlippagePercentage = useAppSelector(selectUserSlippagePercentage) - const [slippageType, setSlippageType] = useState(SlippageType.Auto) - const [slippageAmount, setSlippageAmount] = useState( - defaultSlippagePercentage, - ) - const [isInvalid, setIsInvalid] = useState(false) - const translate = useTranslate() - const inputRef = useRef(null) - const isAdvancedSlippageEnabled = useFeatureFlag('AdvancedSlippage') - const dispatch = useAppDispatch() - - useEffect(() => { - // Handles re-opening the slippage popover and/or going back to input step - if (userSlippagePercentage) { - setSlippageType(SlippageType.Custom) - setSlippageAmount(userSlippagePercentage) - } else { - setSlippageType(SlippageType.Auto) - setSlippageAmount(quoteSlippagePercentage ?? defaultSlippagePercentage) - } - // We only want this to run on mount, though not to be reactive to userSlippagePercentage - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [defaultSlippagePercentage, quoteSlippagePercentage]) - - const handleChange = useCallback((e: React.ChangeEvent) => { - const value = e.target.value - if (bnOrZero(value).gt(maxSlippagePercentage)) { - setIsInvalid(true) - } else { - setIsInvalid(false) - } - setSlippageAmount(value) - setSlippageType(SlippageType.Custom) - }, []) - - const handleClose = useCallback(() => { - if (slippageType === SlippageType.Custom && !isInvalid) - dispatch(tradeInput.actions.setSlippagePreferencePercentage(slippageAmount)) - else if (slippageType === SlippageType.Auto) - dispatch(tradeInput.actions.setSlippagePreferencePercentage(undefined)) - }, [dispatch, isInvalid, slippageAmount, slippageType]) - - const handleSlippageTypeChange = useCallback( - (type: SlippageType) => { - if (type === SlippageType.Auto) { - setSlippageAmount(defaultSlippagePercentage) - setIsInvalid(false) - } else { - inputRef && inputRef.current && inputRef.current.focus() - setSlippageAmount(slippageAmount) - } - setSlippageType(type) + const setSlippagePreferencePercentage = useCallback( + (slippagePercentage: string | undefined) => { + dispatch(tradeInput.actions.setSlippagePreferencePercentage(slippagePercentage)) }, - [defaultSlippagePercentage, slippageAmount], - ) - - const handleAutoSlippageTypeChange = useCallback( - () => handleSlippageTypeChange(SlippageType.Auto), - [handleSlippageTypeChange], + [dispatch], ) - const handleCustomSlippageTypeChange = useCallback( - () => handleSlippageTypeChange(SlippageType.Custom), - [handleSlippageTypeChange], - ) - - const isHighSlippage = useMemo(() => bnOrZero(slippageAmount).gt(1), [slippageAmount]) - const isLowSlippage = useMemo(() => bnOrZero(slippageAmount).lt(0.05), [slippageAmount]) - - if (!isAdvancedSlippageEnabled) return null - return ( - - - - - - - - - - - - - - - - - {slippageType === SlippageType.Auto && 'Auto'} - - - - - - - - - - - - - % - - - - - {isHighSlippage && ( - - - - {translate('trade.slippage.warning')} - - - )} - {isLowSlippage && ( - - - - {translate('trade.slippage.lowSlippage')} - - - )} - - - + ) }, ) diff --git a/src/components/MultiHopTrade/components/SwapperIcons.tsx b/src/components/MultiHopTrade/components/SwapperIcons.tsx new file mode 100644 index 00000000000..98f299c1bd2 --- /dev/null +++ b/src/components/MultiHopTrade/components/SwapperIcons.tsx @@ -0,0 +1,52 @@ +import { Center } from '@chakra-ui/react' +import type { SwapperName, SwapSource } from '@shapeshiftoss/swapper' +import { + THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, + THORCHAIN_STREAM_SWAP_SOURCE, +} from '@shapeshiftoss/swapper/dist/swappers/ThorchainSwapper/constants' +import { AnimatePresence } from 'framer-motion' +import { StreamIcon } from 'components/Icons/Stream' +import { SlideTransitionX } from 'components/SlideTransitionX' + +import { SwapperIcon } from './TradeInput/components/SwapperIcon/SwapperIcon' + +type SwapperIconsProps = { + swapSource: SwapSource | undefined + swapperName: SwapperName | undefined +} + +export const SwapperIcons = ({ swapSource, swapperName }: SwapperIconsProps) => { + const isStreaming = + swapSource === THORCHAIN_STREAM_SWAP_SOURCE || + swapSource === THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE + return ( + + {isStreaming && ( + +
+ +
+
+ )} +
+ {swapperName && } +
+
+ ) +} diff --git a/src/components/MultiHopTrade/components/TradeInput/components/ConfirmSummary.tsx b/src/components/MultiHopTrade/components/TradeInput/components/ConfirmSummary.tsx index 5de4688a4ef..7a3820b0c35 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/ConfirmSummary.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/ConfirmSummary.tsx @@ -363,24 +363,24 @@ export const ConfirmSummary = ({ return ( <> {nativeAssetBridgeWarning ? ( diff --git a/src/state/apis/limit-orders/limitOrderApi.ts b/src/state/apis/limit-orders/limitOrderApi.ts index 7bf175c7d4e..6a2eff9dd99 100644 --- a/src/state/apis/limit-orders/limitOrderApi.ts +++ b/src/state/apis/limit-orders/limitOrderApi.ts @@ -21,6 +21,7 @@ import type { Address } from 'viem' import { zeroAddress } from 'viem' import { BASE_RTK_CREATE_API_CONFIG } from '../const' +import type { LimitOrderQuoteResponse } from './types' import { type CancelLimitOrdersRequest, type CompetitionOrderStatus, @@ -50,7 +51,7 @@ export const limitOrderApi = createApi({ keepUnusedDataFor: Number.MAX_SAFE_INTEGER, // never clear, we will manage this tagTypes: ['LimitOrder'], endpoints: build => ({ - quoteLimitOrder: build.query({ + quoteLimitOrder: build.query({ queryFn: async ({ sellAssetId, buyAssetId, @@ -97,7 +98,7 @@ export const limitOrderApi = createApi({ limitOrderQuoteRequest.appDataHash = appDataHash try { - const result = await axios.post( + const result = await axios.post( `${baseUrl}/${network}/api/v1/quote/`, limitOrderQuoteRequest, ) diff --git a/src/state/apis/limit-orders/types.ts b/src/state/apis/limit-orders/types.ts index 01936356b07..fd334b10701 100644 --- a/src/state/apis/limit-orders/types.ts +++ b/src/state/apis/limit-orders/types.ts @@ -5,6 +5,7 @@ import type { CoWSwapSellTokenSource, CoWSwapSigningScheme, } from '@shapeshiftoss/swapper' +import type { Address } from 'viem' export enum PriceQuality { Fast = 'fast', @@ -30,6 +31,33 @@ export type LimitOrderQuoteRequest = { sellAmountBeforeFee: string } +export type LimitOrderQuote = { + sellToken: AssetReference + buyToken: AssetReference + receiver?: string + sellAmount: string + buyAmount: string + validTo: number + feeAmount: string + kind: CoWSwapOrderKind + partiallyFillable: boolean + sellTokenBalance?: CoWSwapSellTokenSource + buyTokenBalance?: CoWSwapBuyTokenDestination + signingScheme: CoWSwapSigningScheme + signature: string + from?: string + appData: string + appDataHash?: string +} + +export type LimitOrderQuoteResponse = { + expiration: string + from: Address + id: LimitOrderQuoteId + quote: LimitOrderQuote + verified: boolean +} + export type LimitOrder = { sellToken: AssetReference buyToken: AssetReference diff --git a/src/state/slices/tradeQuoteSlice/selectors.ts b/src/state/slices/tradeQuoteSlice/selectors.ts index 014de3dbd6f..db34ad921e2 100644 --- a/src/state/slices/tradeQuoteSlice/selectors.ts +++ b/src/state/slices/tradeQuoteSlice/selectors.ts @@ -555,6 +555,8 @@ type AffiliateFeesProps = { inputAmountUsd: string | undefined } +// TODO: Move out of tradeQuoteSlice as this is used for limit orders also, and is not spot +// specific export const selectCalculatedFees: Selector = createCachedSelector( (_state: ReduxState, { feeModel }: AffiliateFeesProps) => feeModel,