diff --git a/packages/swapper/src/types.ts b/packages/swapper/src/types.ts index 3166e29131b..91f64bc68f0 100644 --- a/packages/swapper/src/types.ts +++ b/packages/swapper/src/types.ts @@ -135,9 +135,8 @@ export type TradeQuoteStep = { estimatedExecutionTimeMs: number | undefined } -export type TradeQuote = { +type TradeQuoteBase = { id: string - steps: TradeQuoteStep[] rate: string // top-level rate for all steps (i.e. output amount / input amount) receiveAddress: string receiveAccountNumber?: number @@ -148,6 +147,35 @@ export type TradeQuote = { isLongtail?: boolean } +// https://github.com/microsoft/TypeScript/pull/40002 +type _TupleOf = R['length'] extends N + ? R + : _TupleOf +type TupleOf = N extends N + ? number extends N + ? T[] + : _TupleOf + : never +// A trade quote can *technically* contain one or many steps, depending on the specific swap/swapper +// However, it *effectively* contains 1 or 2 steps only for now +// Whenever this changes, MultiHopTradeQuoteSteps should be updated to reflect it, with TupleOf +// where n is a sane max number of steps between 3 and 100 +export type SingleHopTradeQuoteSteps = TupleOf +export type MultiHopTradeQuoteSteps = TupleOf + +export type SingleHopTradeQuote = TradeQuoteBase & { + steps: SingleHopTradeQuoteSteps +} +export type MultiHopTradeQuote = TradeQuoteBase & { + steps: MultiHopTradeQuoteSteps +} + +// Note: don't try to do TradeQuote = SingleHopTradeQuote | MultiHopTradeQuote here, which would be cleaner but you'll have type errors such as +// "An interface can only extend an object type or intersection of object types with statically known members." +export type TradeQuote = TradeQuoteBase & { + steps: SingleHopTradeQuoteSteps | MultiHopTradeQuoteSteps +} + export type FromOrXpub = { from: string; xpub?: never } | { from?: never; xpub: string } export type CowSwapOrder = { diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index bf4dcbc4450..6c65d8a4a63 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -748,6 +748,7 @@ "recipientAddress": "Recipient address", "customRecipientAddress": "Custom recipient address", "customRecipientAddressDescription": "Enter a custom recipient address for this trade", + "thisIsYourCustomRecipientAddress": "This is your custom recipient address", "enterCustomRecipientAddress": "Enter custom recipient address", "tooltip": { "rate": "This is the expected rate for this trade pair.", diff --git a/src/components/Modals/Send/AddressInput/AddressInput.tsx b/src/components/Modals/Send/AddressInput/AddressInput.tsx index c58d19cdf1b..008f2c4f774 100644 --- a/src/components/Modals/Send/AddressInput/AddressInput.tsx +++ b/src/components/Modals/Send/AddressInput/AddressInput.tsx @@ -42,6 +42,7 @@ export const AddressInput = ({ rules, placeholder, enableQr = false }: AddressIn value={value} variant='filled' data-test='send-address-input' + data-1p-ignore // Because the InputRightElement is hover the input, we need to let this space free pe={10} isInvalid={!isValid} diff --git a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StepperStep.tsx b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StepperStep.tsx index ac5ba437a5f..4144f9da09a 100644 --- a/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StepperStep.tsx +++ b/src/components/MultiHopTrade/components/MultiHopTradeConfirm/components/StepperStep.tsx @@ -9,8 +9,14 @@ import { StepIndicator, StepSeparator, StepTitle, + Tag, useStyleConfig, } from '@chakra-ui/react' +import { isLedger } from '@shapeshiftoss/hdwallet-ledger' +import { useMemo } from 'react' +import { MiddleEllipsis } from 'components/MiddleEllipsis/MiddleEllipsis' +import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' +import { useWallet } from 'hooks/useWallet/useWallet' const width = { width: '100%' } @@ -43,6 +49,18 @@ export const StepperStep = ({ variant: isError ? 'error' : 'default', }) as { indicator: SystemStyleObject } + const wallet = useWallet().state.wallet + const useReceiveAddressArgs = useMemo( + () => ({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }), + [wallet], + ) + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress(useReceiveAddressArgs) + const receiveAddress = manualReceiveAddress ?? walletReceiveAddress + + if (!receiveAddress) return null + return ( @@ -56,13 +74,20 @@ export const StepperStep = ({ {description && ( - - {isLoading ? ( - - ) : ( - description - )} - + <> + + {isLoading ? ( + + ) : ( + description + )} + + {isLastStep ? ( + + + + ) : null} + )} {content !== undefined && {content}} {!isLastStep && } diff --git a/src/components/MultiHopTrade/components/TradeConfirm/ReceiveSummary.tsx b/src/components/MultiHopTrade/components/TradeConfirm/ReceiveSummary.tsx index 6d3c8987918..9e84073a4da 100644 --- a/src/components/MultiHopTrade/components/TradeConfirm/ReceiveSummary.tsx +++ b/src/components/MultiHopTrade/components/TradeConfirm/ReceiveSummary.tsx @@ -1,28 +1,14 @@ +import { ChevronDownIcon, ChevronUpIcon, QuestionIcon } from '@chakra-ui/icons' import { - CheckIcon, - ChevronDownIcon, - ChevronUpIcon, - CloseIcon, - EditIcon, - QuestionIcon, -} from '@chakra-ui/icons' -import { - Box, Collapse, Divider, Flex, - IconButton, - Input, - InputGroup, - InputRightElement, Skeleton, Stack, - Tooltip, useColorModeValue, useDisclosure, } from '@chakra-ui/react' -import type { AssetId } from '@shapeshiftoss/caip' -import { isLedger } from '@shapeshiftoss/hdwallet-ledger' +import { type AssetId } from '@shapeshiftoss/caip' import type { AmountDisplayMeta, ProtocolFee, SwapSource } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' import type { PartialRecord } from '@shapeshiftoss/types' @@ -30,20 +16,17 @@ import { type FC, memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { Amount } from 'components/Amount/Amount' import { HelperTooltip } from 'components/HelperTooltip/HelperTooltip' -import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' import { Row, type RowProps } from 'components/Row/Row' import { RawText, Text } from 'components/Text' import type { TextPropTypes } from 'components/Text/Text' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' -import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' -import { useWallet } from 'hooks/useWallet/useWallet' import { bnOrZero } from 'lib/bignumber/bignumber' import { fromBaseUnit } from 'lib/math' import { THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, THORCHAIN_STREAM_SWAP_SOURCE, } from 'lib/swapper/swappers/ThorchainSwapper/constants' -import { isSome, middleEllipsis } from 'lib/utils' +import { isSome } from 'lib/utils' import { selectActiveQuoteAffiliateBps, selectQuoteAffiliateFeeUserCurrency, @@ -70,10 +53,6 @@ type ReceiveSummaryProps = { swapSource?: SwapSource } & RowProps -const editIcon = -const checkIcon = -const closeIcon = - const shapeShiftFeeModalRowHover = { textDecoration: 'underline', cursor: 'pointer' } const tradeFeeSourceTranslation: TextPropTypes['translation'] = [ @@ -81,12 +60,6 @@ const tradeFeeSourceTranslation: TextPropTypes['translation'] = [ { tradeFeeSource: 'ShapeShift' }, ] -// TODO(gomes): implement me -const isCustomRecipientAddress = false -const recipientAddressTranslation: TextPropTypes['translation'] = isCustomRecipientAddress - ? 'trade.customRecipientAddress' - : 'trade.recipientAddress' - export const ReceiveSummary: FC = memo( ({ symbol, @@ -162,46 +135,11 @@ export const ReceiveSummary: FC = memo( setShowFeeModal(!showFeeModal) }, [showFeeModal]) - // Recipient address state and handlers - const [isRecipientAddressEditing, setIsRecipientAddressEditing] = useState(false) - const handleEditRecipientAddressClick = useCallback(() => { - setIsRecipientAddressEditing(true) - }, []) - - const handleCancelClick = useCallback(() => { - setIsRecipientAddressEditing(false) - }, []) - - const handleSaveClick = useCallback(() => { - setIsRecipientAddressEditing(false) - }, []) - - const handleInputChange: React.ChangeEventHandler = useCallback(event => { - // TODO(gomes): dispatch here and make the input controlled with a local state value and form context so that - // typing actually does something - // dispatch(tradeInput.actions.setManualReceiveAddress(undefined)) - console.log(event.target.value) - }, []) - const minAmountAfterSlippageTranslation: TextPropTypes['translation'] = useMemo( () => ['trade.minAmountAfterSlippage', { slippage: slippageAsPercentageString }], [slippageAsPercentageString], ) - const wallet = useWallet().state.wallet - const useReceiveAddressArgs = useMemo( - () => ({ - fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), - }), - [wallet], - ) - const isHolisticRecipientAddressEnabled = useFeatureFlag('HolisticRecipientAddress') - - const receiveAddress = useReceiveAddress(useReceiveAddressArgs) - - // This should never happen but it may - if (isHolisticRecipientAddressEnabled && !receiveAddress) return null - return ( <> @@ -351,77 +289,6 @@ export const ReceiveSummary: FC = memo( )} - - {/* TODO(gomes): This should probably be made its own component and removed */} - {/* TODO(gomes): we can safely remove this condition when this feature goes live */} - {isHolisticRecipientAddressEnabled && - receiveAddress && - (isRecipientAddressEditing ? ( - - - - - - {checkIcon} - - - {closeIcon} - - - - - ) : ( - <> - - - - - - - - {middleEllipsis(receiveAddress)} - - - - - - - - ))} diff --git a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx index a1a1b7d9c4f..82bc33c352d 100644 --- a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx @@ -37,6 +37,7 @@ import { getMixpanelEventData } from 'components/MultiHopTrade/helpers' import { usePriceImpact } from 'components/MultiHopTrade/hooks/quoteValidation/usePriceImpact' import { checkApprovalNeeded } from 'components/MultiHopTrade/hooks/useAllowanceApproval/helpers' import { useGetTradeQuotes } from 'components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes' +import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' import { TradeRoutePaths } from 'components/MultiHopTrade/types' import { SlideTransition } from 'components/SlideTransition' import { Text } from 'components/Text' @@ -49,6 +50,10 @@ import { bnOrZero, positiveOrZero } from 'lib/bignumber/bignumber' import { fromBaseUnit } from 'lib/math' import { getMixPanel } from 'lib/mixpanel/mixPanelSingleton' import { MixPanelEvent } from 'lib/mixpanel/types' +import { + THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, + THORCHAIN_LONGTAIL_SWAP_SOURCE, +} from 'lib/swapper/swappers/ThorchainSwapper/constants' import type { ThorTradeQuote } from 'lib/swapper/swappers/ThorchainSwapper/getThorTradeQuote/getTradeQuote' import { isKeplrHDWallet, isToken } from 'lib/utils' import { selectIsSnapshotApiQueriesPending, selectVotingPower } from 'state/apis/snapshot/selectors' @@ -56,6 +61,7 @@ import { selectInputBuyAsset, selectInputSellAmountCryptoPrecision, selectInputSellAsset, + selectManualReceiveAddressIsValid, selectManualReceiveAddressIsValidating, } from 'state/slices/selectors' import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' @@ -82,6 +88,7 @@ import { useAppDispatch, useAppSelector } from 'state/store' import { useAccountIds } from '../../hooks/useAccountIds' import { useSupportedAssets } from '../../hooks/useSupportedAssets' import { PriceImpact } from '../PriceImpact' +import { RecipientAddress } from './components/RecipientAddress' import { SellAssetInput } from './components/SellAssetInput' import { TradeQuotes } from './components/TradeQuotes/TradeQuotes' import { getQuoteErrorTranslation } from './getQuoteErrorTranslation' @@ -141,6 +148,7 @@ export const TradeInput = memo(() => { const buyAmountAfterFeesUserCurrency = useAppSelector(selectBuyAmountAfterFeesUserCurrency) const totalNetworkFeeFiatPrecision = useAppSelector(selectTotalNetworkFeeUserCurrencyPrecision) const manualReceiveAddressIsValidating = useAppSelector(selectManualReceiveAddressIsValidating) + const manualReceiveAddressIsValid = useAppSelector(selectManualReceiveAddressIsValid) const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) const slippageDecimal = useAppSelector(selectTradeSlippagePercentageDecimal) const activeQuoteErrors = useAppSelector(selectActiveQuoteErrors) @@ -255,35 +263,62 @@ export const TradeInput = memo(() => { return fromAccountId(initialSellAssetAccountId).account }, [initialSellAssetAccountId]) - const { data: _isSmartContractAddress, isLoading: isAddressByteCodeLoading } = + const useReceiveAddressArgs = useMemo( + () => ({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }), + [wallet], + ) + + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress(useReceiveAddressArgs) + const receiveAddress = manualReceiveAddress ?? walletReceiveAddress + + const { data: _isSmartContractSellAddress, isLoading: isSellAddressByteCodeLoading } = useIsSmartContractAddress(userAddress) + const { data: _isSmartContractReceiveAddress, isLoading: isReceiveAddressByteCodeLoading } = + useIsSmartContractAddress(receiveAddress ?? '') + const disableSmartContractSwap = useMemo(() => { // Swappers other than THORChain shouldn't be affected by this limitation if (activeSwapperName !== SwapperName.Thorchain) return false // This is either a smart contract address, or the bytecode is still loading - disable confirm - if (_isSmartContractAddress !== false) return true + if (_isSmartContractSellAddress !== false) return true + if ( + [THORCHAIN_LONGTAIL_SWAP_SOURCE, THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE].includes( + tradeQuoteStep?.source!, + ) && + _isSmartContractReceiveAddress !== false + ) + return true // All checks passed - this is an EOA address return false - }, [_isSmartContractAddress, activeSwapperName]) + }, [ + _isSmartContractReceiveAddress, + _isSmartContractSellAddress, + activeSwapperName, + tradeQuoteStep?.source, + ]) const isLoading = useMemo( () => !isAnySwapperFetched || isConfirmationLoading || isSupportedAssetsLoading || - isAddressByteCodeLoading || + isSellAddressByteCodeLoading || + isReceiveAddressByteCodeLoading || // 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, [ - isAddressByteCodeLoading, - isConfirmationLoading, isAnySwapperFetched, + isConfirmationLoading, isSupportedAssetsLoading, + isSellAddressByteCodeLoading, + isReceiveAddressByteCodeLoading, isVotingPowerLoading, ], ) @@ -398,6 +433,7 @@ export const TradeInput = memo(() => { return ( quoteHasError || manualReceiveAddressIsValidating || + manualReceiveAddressIsValid === false || isLoading || !hasUserEnteredAmount || !activeQuote || @@ -406,14 +442,15 @@ export const TradeInput = memo(() => { isSwapperFetching[activeSwapperName] ) }, [ - activeQuote, - activeSwapperName, - disableSmartContractSwap, + quoteHasError, + manualReceiveAddressIsValidating, + manualReceiveAddressIsValid, isLoading, hasUserEnteredAmount, + activeQuote, + disableSmartContractSwap, + activeSwapperName, isSwapperFetching, - manualReceiveAddressIsValidating, - quoteHasError, ]) const maybeUnsafeTradeWarning = useMemo(() => { @@ -487,6 +524,7 @@ export const TradeInput = memo(() => { swapSource={tradeQuoteStep?.source} /> ) : null} + {isModeratePriceImpact && ( )} diff --git a/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx b/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx index 97a27218059..4e25820b1d6 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/ManualAddressEntry.tsx @@ -1,11 +1,13 @@ import { FormControl, FormLabel, Link } from '@chakra-ui/react' import { ethChainId } from '@shapeshiftoss/caip' +import { isLedger } from '@shapeshiftoss/hdwallet-ledger' import type { FC } from 'react' import { memo, useCallback, useEffect, useMemo } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslate } from 'react-polyglot' import { AddressInput } from 'components/Modals/Send/AddressInput/AddressInput' import { SendFormFields } from 'components/Modals/Send/SendCommon' +import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' import { useIsSnapInstalled } from 'hooks/useIsSnapInstalled/useIsSnapInstalled' @@ -13,11 +15,9 @@ import { useModal } from 'hooks/useModal/useModal' import { useWallet } from 'hooks/useWallet/useWallet' import { useWalletSupportsChain } from 'hooks/useWalletSupportsChain/useWalletSupportsChain' import { parseAddressInputWithChainId } from 'lib/address/address' -import { - selectInputBuyAsset, - selectManualReceiveAddress, -} from 'state/slices/tradeInputSlice/selectors' +import { selectInputBuyAsset } from 'state/slices/tradeInputSlice/selectors' import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' +import { selectActiveQuote } from 'state/slices/tradeQuoteSlice/selectors' import { useAppDispatch, useAppSelector } from 'state/store' export const ManualAddressEntry: FC = memo((): JSX.Element | null => { @@ -44,11 +44,26 @@ export const ManualAddressEntry: FC = memo((): JSX.Element | null => { wallet, isSnapInstalled, }) - const shouldShowManualReceiveAddressInput = !walletSupportsBuyAssetChain + const activeQuote = useAppSelector(selectActiveQuote) + const isHolisticRecipientAddressEnabled = useFeatureFlag('HolisticRecipientAddress') + const shouldShowManualReceiveAddressInput = useMemo(() => { + // Use old "wallet does not support chain" when the flag is off so we always display this + if (!isHolisticRecipientAddressEnabled) return !walletSupportsBuyAssetChain + // If the flag is on, we want to display this if the wallet doesn't support the buy asset chain, + // but stop displaying it as soon as we have a quote + return !walletSupportsBuyAssetChain && !activeQuote + }, [activeQuote, isHolisticRecipientAddressEnabled, walletSupportsBuyAssetChain]) + + const useReceiveAddressArgs = useMemo( + () => ({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }), + [wallet], + ) + const { manualReceiveAddress } = useReceiveAddress(useReceiveAddressArgs) const chainAdapterManager = getChainAdapterManager() const buyAssetChainName = chainAdapterManager.get(buyAssetChainId)?.getDisplayName() - const manualReceiveAddress = useAppSelector(selectManualReceiveAddress) // Trigger re-validation of the manually entered receive address useEffect(() => { @@ -64,7 +79,7 @@ export const ManualAddressEntry: FC = memo((): JSX.Element | null => { // If we have a valid manual receive address, set it in the form useEffect(() => { manualReceiveAddress && setFormValue(SendFormFields.Input, manualReceiveAddress) - }, [dispatch, manualReceiveAddress, setFormValue]) + }, [manualReceiveAddress, setFormValue]) useEffect(() => { dispatch(tradeInput.actions.setManualReceiveAddressIsValidating(isValidating)) diff --git a/src/components/MultiHopTrade/components/TradeInput/components/RecipientAddress.tsx b/src/components/MultiHopTrade/components/TradeInput/components/RecipientAddress.tsx new file mode 100644 index 00000000000..71914003630 --- /dev/null +++ b/src/components/MultiHopTrade/components/TradeInput/components/RecipientAddress.tsx @@ -0,0 +1,232 @@ +import { CheckIcon, CloseIcon, EditIcon } from '@chakra-ui/icons' +import { + FormControl, + IconButton, + InputGroup, + InputRightElement, + Stack, + Tag, + TagCloseButton, + TagLabel, + Tooltip, +} from '@chakra-ui/react' +import { ethChainId } from '@shapeshiftoss/caip' +import { isLedger } from '@shapeshiftoss/hdwallet-ledger' +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { FieldValues } from 'react-hook-form' +import { useFormContext, useWatch } from 'react-hook-form' +import { useTranslate } from 'react-polyglot' +import { AddressInput } from 'components/Modals/Send/AddressInput/AddressInput' +import type { SendInput } from 'components/Modals/Send/Form' +import { SendFormFields } from 'components/Modals/Send/SendCommon' +import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' +import { Row } from 'components/Row/Row' +import { RawText, Text } from 'components/Text' +import type { TextPropTypes } from 'components/Text/Text' +import { useFeatureFlag } from 'hooks/useFeatureFlag/useFeatureFlag' +import { useWallet } from 'hooks/useWallet/useWallet' +import { parseAddressInputWithChainId } from 'lib/address/address' +import { middleEllipsis } from 'lib/utils' +import { selectInputBuyAsset } from 'state/slices/selectors' +import { tradeInput } from 'state/slices/tradeInputSlice/tradeInputSlice' +import { useAppDispatch, useAppSelector } from 'state/store' + +const editIcon = +const checkIcon = +const closeIcon = + +export const RecipientAddress = () => { + const translate = useTranslate() + const isHolisticRecipientAddressEnabled = useFeatureFlag('HolisticRecipientAddress') + const isYatFeatureEnabled = useFeatureFlag('Yat') + const dispatch = useAppDispatch() + const wallet = useWallet().state.wallet + const useReceiveAddressArgs = useMemo( + () => ({ + fetchUnchainedAddress: Boolean(wallet && isLedger(wallet)), + }), + [wallet], + ) + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress(useReceiveAddressArgs) + const receiveAddress = manualReceiveAddress ?? walletReceiveAddress + const { chainId: buyAssetChainId, assetId: buyAssetAssetId } = useAppSelector(selectInputBuyAsset) + const isYatSupportedByReceiveChain = buyAssetChainId === ethChainId // yat only supports eth mainnet + const isYatSupported = isYatFeatureEnabled && isYatSupportedByReceiveChain + const { + formState: { isValidating, isValid }, + // trigger: formTrigger, // TODO(gomes): do we need this? + setValue: setFormValue, + handleSubmit, + } = useFormContext() + + const value = useWatch({ name: SendFormFields.Input }) + const [isRecipientAddressEditing, setIsRecipientAddressEditing] = useState(false) + + // If we have a valid manual receive address, set it in the form + useEffect(() => { + manualReceiveAddress && setFormValue(SendFormFields.Input, manualReceiveAddress) + }, [manualReceiveAddress, setFormValue]) + + useEffect(() => { + dispatch(tradeInput.actions.setManualReceiveAddressIsValidating(isValidating)) + }, [dispatch, isValidating]) + useEffect(() => { + if (!isRecipientAddressEditing) return + + // minLength should catch this and make isValid false, but doesn't seem to on mount, even when manually triggering validation. + if (!value.length) { + dispatch(tradeInput.actions.setManualReceiveAddressIsValid(false)) + return + } + // We only want to set this when editing. Failure to do so will catch the initial '' invalid value (because of the minLength: 1) + // and prevent continuing with the trade, when there is no manual receive address + dispatch(tradeInput.actions.setManualReceiveAddressIsValid(isValid)) + }, [isValid, dispatch, isRecipientAddressEditing, value]) + + const isCustomRecipientAddress = Boolean(manualReceiveAddress) + const recipientAddressTranslation: TextPropTypes['translation'] = isCustomRecipientAddress + ? 'trade.customRecipientAddress' + : 'trade.recipientAddress' + + const rules = useMemo( + () => ({ + required: true, + validate: { + validateAddress: async (rawInput: string) => { + try { + const value = rawInput.trim() // trim leading/trailing spaces + // this does not throw, everything inside is handled + const parseAddressInputWithChainIdArgs = { + assetId: buyAssetAssetId, + chainId: buyAssetChainId, + urlOrAddress: value, + disableUrlParsing: true, + } + const { address } = await parseAddressInputWithChainId(parseAddressInputWithChainIdArgs) + const invalidMessage = isYatSupported + ? 'common.invalidAddressOrYat' + : 'common.invalidAddress' + return address ? true : invalidMessage + } catch (e) { + // This function should never throw, but in case it ever does, we never want to have a stale manual receive address stored + console.error(e) + dispatch(tradeInput.actions.setManualReceiveAddress(undefined)) + } + }, + }, + minLength: 1, + }), + [buyAssetAssetId, buyAssetChainId, dispatch, isYatSupported], + ) + + const handleEditRecipientAddressClick = useCallback(() => { + setIsRecipientAddressEditing(true) + }, []) + + const handleCancelClick = useCallback(() => { + setIsRecipientAddressEditing(false) + // Reset form value and valid state on cancel so the valid check doesn't wrongly evaluate to false after bailing out of editing an invalid address + setFormValue(SendFormFields.Input, '') + dispatch(tradeInput.actions.setManualReceiveAddressIsValid(undefined)) + }, [dispatch, setFormValue]) + + const resetManualReceiveAddress = useCallback(() => { + // Reset the manual receive address in store + dispatch(tradeInput.actions.setManualReceiveAddress(undefined)) + // Reset the valid state in store + dispatch(tradeInput.actions.setManualReceiveAddressIsValid(undefined)) + // And also the form value itself, to avoid the user going from + // custom recipient -> cleared custom recipient -> custom recipient where the previously set custom recipient + // would be displayed, wrongly hinting this is the default wallet address + setFormValue(SendFormFields.Input, '') + }, [dispatch, setFormValue]) + + const onSubmit = useCallback( + (values: FieldValues) => { + // We don't need to revalidate here as submit will only be enabled if the form is valid + const address = values[SendFormFields.Input] + dispatch(tradeInput.actions.setManualReceiveAddress(address)) + setIsRecipientAddressEditing(false) + }, + [dispatch], + ) + + const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]) + + if (!isHolisticRecipientAddressEnabled) return null + if (!receiveAddress) return null + + return isRecipientAddressEditing ? ( +
+ + + + + + + + + {Boolean(value.length && !isValid) && ( + + )} + +
+ ) : ( + + + + + + {isCustomRecipientAddress ? ( + + + {middleEllipsis(receiveAddress)} + + + + ) : ( + + {middleEllipsis(receiveAddress)} + + + + + )} + + + ) +} diff --git a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx index 948cdc222f7..cc780f6e5ab 100644 --- a/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx +++ b/src/components/MultiHopTrade/hooks/useGetTradeQuotes/useGetTradeQuotes.tsx @@ -118,7 +118,8 @@ export const useGetTradeQuotes = () => { }), [wallet], ) - const receiveAddress = useReceiveAddress(useReceiveAddressArgs) + const { manualReceiveAddress, walletReceiveAddress } = useReceiveAddress(useReceiveAddressArgs) + const receiveAddress = manualReceiveAddress ?? walletReceiveAddress const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) const debouncedSellAmountCryptoPrecision = useDebounce(sellAmountCryptoPrecision, 500) const isDebouncing = debouncedSellAmountCryptoPrecision !== sellAmountCryptoPrecision diff --git a/src/components/MultiHopTrade/hooks/useReceiveAddress.tsx b/src/components/MultiHopTrade/hooks/useReceiveAddress.tsx index f54d128c8f8..0323747c920 100644 --- a/src/components/MultiHopTrade/hooks/useReceiveAddress.tsx +++ b/src/components/MultiHopTrade/hooks/useReceiveAddress.tsx @@ -34,7 +34,7 @@ export const useReceiveAddress = ({ // Hooks const wallet = useWallet().state.wallet // TODO: this should live in redux - const [receiveAddress, setReceiveAddress] = useState(undefined) + const [walletReceiveAddress, setWalletReceiveAddress] = useState(undefined) // Selectors const buyAsset = useAppSelector(selectInputBuyAsset) @@ -79,14 +79,14 @@ export const useReceiveAddress = ({ ;(async () => { try { const updatedReceiveAddress = await getReceiveAddressFromBuyAsset(buyAsset) - setReceiveAddress(updatedReceiveAddress) + setWalletReceiveAddress(updatedReceiveAddress) } catch (e) { console.error(e) - setReceiveAddress(undefined) + setWalletReceiveAddress(undefined) } })() }, [buyAsset, getReceiveAddressFromBuyAsset]) // Always use the manual receive address if it is set - return manualReceiveAddress || receiveAddress + return { manualReceiveAddress, walletReceiveAddress } } diff --git a/src/components/MultiHopTrade/utils.ts b/src/components/MultiHopTrade/utils.ts index ee2f05c514d..78a25afa53a 100644 --- a/src/components/MultiHopTrade/utils.ts +++ b/src/components/MultiHopTrade/utils.ts @@ -1,5 +1,5 @@ import type { AssetId, ChainId } from '@shapeshiftoss/caip' -import type { SwapErrorRight } from '@shapeshiftoss/swapper' +import type { MultiHopTradeQuote, SwapErrorRight, TradeQuote } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' @@ -50,3 +50,6 @@ export const isTradingActive = async ( // All chains currently support Tx history, but that might not be the case as we support more chains export const chainSupportsTxHistory = (_chainId: ChainId): boolean => true + +export const isMultiHopTradeQuote = (quote: TradeQuote): quote is MultiHopTradeQuote => + quote.steps.length > 1 diff --git a/src/lib/swapper/swappers/LifiSwapper/getTradeQuote/getTradeQuote.ts b/src/lib/swapper/swappers/LifiSwapper/getTradeQuote/getTradeQuote.ts index 5a78232cb9d..996ce2caebb 100644 --- a/src/lib/swapper/swappers/LifiSwapper/getTradeQuote/getTradeQuote.ts +++ b/src/lib/swapper/swappers/LifiSwapper/getTradeQuote/getTradeQuote.ts @@ -2,7 +2,12 @@ import type { ChainKey, LifiError, RoutesRequest } from '@lifi/sdk' import { LifiErrorCode } from '@lifi/sdk' import type { AssetId, ChainId } from '@shapeshiftoss/caip' import { fromChainId } from '@shapeshiftoss/caip' -import type { GetEvmTradeQuoteInput, SwapSource } from '@shapeshiftoss/swapper' +import type { + GetEvmTradeQuoteInput, + MultiHopTradeQuoteSteps, + SingleHopTradeQuoteSteps, + SwapSource, +} from '@shapeshiftoss/swapper' import { makeSwapErrorRight, type SwapErrorRight, @@ -134,7 +139,7 @@ export async function getTradeQuote( routes.slice(0, 3).map(async selectedLifiRoute => { // this corresponds to a "hop", so we could map the below code over selectedLifiRoute.steps to // generate a multi-hop quote - const steps = await Promise.all( + const steps = (await Promise.all( selectedLifiRoute.steps.map(async lifiStep => { const stepSellAsset = lifiTokenToAsset(lifiStep.action.fromToken, assets) const stepChainId = stepSellAsset.chainId @@ -212,7 +217,7 @@ export async function getTradeQuote( estimatedExecutionTimeMs: 1000 * lifiStep.estimate.executionDuration, } }), - ) + )) as SingleHopTradeQuoteSteps | MultiHopTradeQuoteSteps // The rate for the entire multi-hop swap const netRate = convertPrecision({ diff --git a/src/lib/swapper/swappers/OneInchSwapper/getTradeQuote/getTradeQuote.ts b/src/lib/swapper/swappers/OneInchSwapper/getTradeQuote/getTradeQuote.ts index 7c490fe3fdc..61a0112a434 100644 --- a/src/lib/swapper/swappers/OneInchSwapper/getTradeQuote/getTradeQuote.ts +++ b/src/lib/swapper/swappers/OneInchSwapper/getTradeQuote/getTradeQuote.ts @@ -1,5 +1,9 @@ import { fromChainId } from '@shapeshiftoss/caip' -import type { GetEvmTradeQuoteInput, TradeQuote } from '@shapeshiftoss/swapper' +import type { + GetEvmTradeQuoteInput, + SingleHopTradeQuoteSteps, + TradeQuote, +} from '@shapeshiftoss/swapper' import { makeSwapErrorRight, type SwapErrorRight, @@ -119,7 +123,7 @@ export async function getTradeQuote( }, source: SwapperName.OneInch, }, - ], + ] as SingleHopTradeQuoteSteps, }) } catch (err) { return Err( diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/getLongtailQuote.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/getLongtailQuote.ts index e1328b3cc12..04834a8e30b 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/getLongtailQuote.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/getLongtailQuote.ts @@ -1,6 +1,6 @@ import { ethChainId } from '@shapeshiftoss/caip' import type { EvmChainId } from '@shapeshiftoss/chain-adapters' -import type { GetTradeQuoteInput } from '@shapeshiftoss/swapper' +import type { GetTradeQuoteInput, MultiHopTradeQuoteSteps } from '@shapeshiftoss/swapper' import { makeSwapErrorRight, type SwapErrorRight, TradeQuoteError } from '@shapeshiftoss/swapper' import type { AssetsByIdPartial } from '@shapeshiftoss/types' import type { Result } from '@sniptt/monads' @@ -136,7 +136,8 @@ export const getLongtailToL1Quote = async ( input.sellAmountIncludingProtocolFeesCryptoBaseUnit, sellAsset: input.sellAsset, allowanceContract: ALLOWANCE_CONTRACT, - })), + })) as MultiHopTradeQuoteSteps, // assuming multi-hop quote steps here since we're mapping over quote steps + isLongtail: true, longtailData: { longtailToL1ExpectedAmountOut: quotedAmountOut, diff --git a/src/lib/swapper/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts b/src/lib/swapper/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts index 0e1e0791ff0..b3d1ab05edd 100644 --- a/src/lib/swapper/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts +++ b/src/lib/swapper/swappers/ZrxSwapper/getZrxTradeQuote/getZrxTradeQuote.ts @@ -1,4 +1,8 @@ -import type { GetEvmTradeQuoteInput, TradeQuote } from '@shapeshiftoss/swapper' +import type { + GetEvmTradeQuoteInput, + SingleHopTradeQuoteSteps, + TradeQuote, +} from '@shapeshiftoss/swapper' import { makeSwapErrorRight, type SwapErrorRight, @@ -137,7 +141,7 @@ export async function getZrxTradeQuote( sellAmountIncludingProtocolFeesCryptoBaseUnit, source: SwapperName.Zrx, }, - ], + ] as SingleHopTradeQuoteSteps, }) } catch (err) { return Err( diff --git a/src/state/apis/swapper/helpers/validateTradeQuote.ts b/src/state/apis/swapper/helpers/validateTradeQuote.ts index f0928f453f3..075f6bd14a0 100644 --- a/src/state/apis/swapper/helpers/validateTradeQuote.ts +++ b/src/state/apis/swapper/helpers/validateTradeQuote.ts @@ -1,12 +1,17 @@ import type { AssetId } from '@shapeshiftoss/caip' -import type { ProtocolFee, SwapErrorRight, TradeQuote } from '@shapeshiftoss/swapper' +import type { ProtocolFee, SwapErrorRight, SwapSource, TradeQuote } from '@shapeshiftoss/swapper' import { SwapperName, TradeQuoteError as SwapperTradeQuoteError } from '@shapeshiftoss/swapper' import type { KnownChainIds } from '@shapeshiftoss/types' import { getChainShortName } from 'components/MultiHopTrade/components/MultiHopTradeConfirm/utils/getChainShortName' +import { isMultiHopTradeQuote } from 'components/MultiHopTrade/utils' // import { isTradingActive } from 'components/MultiHopTrade/utils' import { isSmartContractAddress } from 'lib/address/utils' import { baseUnitToHuman, bn, bnOrZero } from 'lib/bignumber/bignumber' import { fromBaseUnit } from 'lib/math' +import { + THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE, + THORCHAIN_LONGTAIL_SWAP_SOURCE, +} from 'lib/swapper/swappers/ThorchainSwapper/constants' import type { ThorTradeQuote } from 'lib/swapper/swappers/ThorchainSwapper/getThorTradeQuote/getTradeQuote' import { assertGetChainAdapter, assertUnreachable, isTruthy } from 'lib/utils' import type { ReduxState } from 'state/reducer' @@ -108,10 +113,12 @@ export const validateTradeQuote = async ( // This should really never happen but in case it does: if (!sendAddress) throw new Error('sendAddress is required') - const isMultiHopTrade = quote.steps.length > 1 const firstHop = quote.steps[0] const secondHop = quote.steps[1] - const lastHop = isMultiHopTrade ? secondHop : firstHop + + const isMultiHopTrade = isMultiHopTradeQuote(quote) + + const lastHop = (isMultiHopTrade ? secondHop : firstHop)! const walletSupportedChainIds = selectWalletSupportedChainIds(state) const sellAmountCryptoPrecision = selectInputSellAmountCryptoPrecision(state) const sellAmountCryptoBaseUnit = firstHop.sellAmountIncludingProtocolFeesCryptoBaseUnit @@ -121,9 +128,10 @@ export const validateTradeQuote = async ( const firstHopSellFeeAsset = selectFeeAssetById(state, firstHop.sellAsset.assetId) // the network fee asset for the second hop in the trade - const secondHopSellFeeAsset = isMultiHopTrade - ? selectFeeAssetById(state, secondHop.sellAsset.assetId) - : undefined + const secondHopSellFeeAsset = + isMultiHopTrade && secondHop + ? selectFeeAssetById(state, secondHop.sellAsset.assetId) + : undefined // this is the account we're selling from - network fees are paid from the sell account for the current hop const firstHopSellAccountId = selectFirstHopSellAccountId(state) @@ -151,7 +159,7 @@ export const validateTradeQuote = async ( : bn(0).toFixed() const secondHopNetworkFeeCryptoPrecision = - networkFeeRequiresBalance && secondHopSellFeeAsset + networkFeeRequiresBalance && secondHopSellFeeAsset && secondHop ? fromBaseUnit( bnOrZero(secondHop.feeData.networkFeeCryptoBaseUnit), secondHopSellFeeAsset.precision, @@ -221,9 +229,24 @@ export const validateTradeQuote = async ( // Swappers other than THORChain shouldn't be affected by this limitation if (swapperName !== SwapperName.Thorchain) return false - // Sender is either a smart contract address, or the bytecode is still loading - disable confirm - const _isSmartContractAddress = await isSmartContractAddress(sendAddress) - if (_isSmartContractAddress !== false) return true + // This is either a smart contract address, or the bytecode is still loading - disable confirm + const _isSmartContractSellAddress = await isSmartContractAddress(sendAddress) + const _isSmartContractReceiveAddress = await isSmartContractAddress(quote.receiveAddress) + // For long-tails, the *destination* address cannot be a smart contract + // https://dev.thorchain.org/aggregators/aggregator-overview.html#admonition-warning + // This doesn't apply to regular THOR swaps however, which docs have no mention of *destination* having to be an EOA + // https://dev.thorchain.org/protocol-development/chain-clients/evm-chains.html?search=smart%20contract + if ( + [firstHop.source, secondHop?.source ?? ('' as SwapSource)].some(source => + [THORCHAIN_LONGTAIL_SWAP_SOURCE, THORCHAIN_LONGTAIL_STREAMING_SWAP_SOURCE].includes(source), + ) && + _isSmartContractReceiveAddress !== false + ) + return true + // Regardless of whether this is a long-tail or not, the *source* address should never be a smart contract + // https://dev.thorchain.org/concepts/sending-transactions.html?highlight=smart%20congtract%20address#admonition-danger-2 + // https://dev.thorchain.org/protocol-development/chain-clients/evm-chains.html?highlight=smart%20congtract%20address#admonition-warning-1 + if (_isSmartContractSellAddress !== false) return true // All checks passed - this is an EOA address return false @@ -250,13 +273,14 @@ export const validateTradeQuote = async ( ).getDisplayName(), }, }, - !walletSupportsIntermediaryAssetChain && { - error: TradeQuoteValidationError.IntermediaryAssetNotNotSupportedByWallet, - meta: { - assetSymbol: secondHop.sellAsset.symbol, - chainSymbol: getChainShortName(secondHop.sellAsset.chainId as KnownChainIds), + !walletSupportsIntermediaryAssetChain && + secondHop && { + error: TradeQuoteValidationError.IntermediaryAssetNotNotSupportedByWallet, + meta: { + assetSymbol: secondHop.sellAsset.symbol, + chainSymbol: getChainShortName(secondHop.sellAsset.chainId as KnownChainIds), + }, }, - }, !firstHopHasSufficientBalanceForGas && { error: TradeQuoteValidationError.InsufficientFirstHopFeeAssetBalance, meta: { diff --git a/src/state/slices/tradeInputSlice/selectors.ts b/src/state/slices/tradeInputSlice/selectors.ts index a7003abc81b..f5f52d4a420 100644 --- a/src/state/slices/tradeInputSlice/selectors.ts +++ b/src/state/slices/tradeInputSlice/selectors.ts @@ -147,6 +147,11 @@ export const selectManualReceiveAddressIsValidating = createSelector( tradeInput => tradeInput.manualReceiveAddressIsValidating, ) +export const selectManualReceiveAddressIsValid = createSelector( + selectTradeInput, + tradeInput => tradeInput.manualReceiveAddressIsValid, +) + export const selectInputSellAmountUsd = createSelector( selectInputSellAmountCryptoPrecision, selectInputSellAssetUsdRate, diff --git a/src/state/slices/tradeInputSlice/tradeInputSlice.ts b/src/state/slices/tradeInputSlice/tradeInputSlice.ts index 258b9b6f11d..bb5b3b71965 100644 --- a/src/state/slices/tradeInputSlice/tradeInputSlice.ts +++ b/src/state/slices/tradeInputSlice/tradeInputSlice.ts @@ -16,6 +16,7 @@ export type TradeInputState = { sellAmountCryptoPrecision: string manualReceiveAddress: string | undefined manualReceiveAddressIsValidating: boolean + manualReceiveAddressIsValid: boolean | undefined slippagePreferencePercentage: string | undefined } @@ -28,6 +29,7 @@ const initialState: TradeInputState = { sellAmountCryptoPrecision: '0', manualReceiveAddress: undefined, manualReceiveAddressIsValidating: false, + manualReceiveAddressIsValid: undefined, slippagePreferencePercentage: undefined, } @@ -76,6 +78,9 @@ export const tradeInput = createSlice({ setManualReceiveAddressIsValidating: (state, action: PayloadAction) => { state.manualReceiveAddressIsValidating = action.payload }, + setManualReceiveAddressIsValid(state, action: PayloadAction) { + state.manualReceiveAddressIsValid = action.payload + }, setSlippagePreferencePercentage: (state, action: PayloadAction) => { state.slippagePreferencePercentage = action.payload }, diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index cea6e4b26e6..9c41b6c11c1 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -178,6 +178,7 @@ export const mockStore: ReduxState = { sellAmountCryptoPrecision: '0', manualReceiveAddress: undefined, manualReceiveAddressIsValidating: false, + manualReceiveAddressIsValid: undefined, slippagePreferencePercentage: undefined, }, tradeQuoteSlice: {