diff --git a/.env b/.env index c10f0c95182..b5fe4545e9b 100644 --- a/.env +++ b/.env @@ -312,3 +312,4 @@ VITE_FEATURE_YIELD_XYZ=false VITE_FEATURE_YIELDS_PAGE=false VITE_YIELD_XYZ_API_KEY=06903960-e442-4870-81eb-03ff3ad4c035 VITE_FEATURE_YIELD_MULTI_ACCOUNT=false +VITE_FEATURE_EARN_TAB=false diff --git a/.env.development b/.env.development index 869fc4c9098..7ee5b06ee99 100644 --- a/.env.development +++ b/.env.development @@ -98,3 +98,4 @@ VITE_FEATURE_NEAR=true VITE_FEATURE_KATANA=true VITE_FEATURE_YIELD_XYZ=true VITE_FEATURE_YIELDS_PAGE=true +VITE_FEATURE_EARN_TAB=true diff --git a/src/Routes/RoutesCommon.tsx b/src/Routes/RoutesCommon.tsx index 341d58e45a4..ea0ff606aed 100644 --- a/src/Routes/RoutesCommon.tsx +++ b/src/Routes/RoutesCommon.tsx @@ -28,6 +28,7 @@ import { History } from '@/pages/History/History' import { RFOX } from '@/pages/RFOX/RFOX' import { TCYNavIndicator } from '@/pages/TCY/components/TCYNavIndicator' import { TCY } from '@/pages/TCY/tcy' +import { EarnTab } from '@/pages/Trade/tabs/EarnTab' import { LimitTab } from '@/pages/Trade/tabs/LimitTab' import { RampTab } from '@/pages/Trade/tabs/RampTab' import { TradeTab } from '@/pages/Trade/tabs/TradeTab' @@ -37,6 +38,8 @@ export const TRADE_ROUTE_ASSET_SPECIFIC = '/trade/:chainId/:assetSubId/:sellChainId/:sellAssetSubId/:sellAmountCryptoBaseUnit' export const LIMIT_ORDER_ROUTE_ASSET_SPECIFIC = '/limit/:chainId/:assetSubId/:sellChainId/:sellAssetSubId/:sellAmountCryptoBaseUnit/:limitPriceMode/:limitPriceDirection/:limitPrice' +export const EARN_ROUTE_ASSET_SPECIFIC = + '/earn/:sellChainId/:sellAssetSubId/:yieldId/:sellAmountCryptoBaseUnit' const Dashboard = makeSuspenseful( lazy(() => @@ -426,4 +429,18 @@ export const routes: Route[] = [ }, ], }, + { + path: '/earn/*', + label: '', + hideDesktop: true, + main: EarnTab, + disable: !getConfig().VITE_FEATURE_EARN_TAB, + routes: [ + { + path: EARN_ROUTE_ASSET_SPECIFIC, + main: EarnTab, + hide: true, + }, + ], + }, ] diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 115ede0c8de..ae1c50d2548 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -48,6 +48,7 @@ "insufficientAmountForGas": "Not enough %{assetSymbol}.%{chainSymbol} to cover gas", "invalidAddress": "Invalid Address", "deposit": "Deposit", + "supply": "Supply", "withdraw": "Withdraw", "withdrawal": "Withdrawal", "claim": "Claim", @@ -520,7 +521,8 @@ "markets": "Markets", "tokens": "Tokens", "swap": "Swap", - "yields": "Yields" + "yields": "Yields", + "earn": "Earn" }, "shapeShiftMenu": { "products": "Products", @@ -2757,6 +2759,8 @@ "successEnter": "You successfully entered %{amount} %{symbol}", "successExit": "You successfully exited %{amount} %{symbol}", "successClaim": "You successfully claimed %{amount} %{symbol}", + "viewPosition": "View Position", + "via": "via", "resetAllowance": "Reset Allowance", "transactionNumber": "Transaction %{number}", "loading": { @@ -2782,5 +2786,30 @@ "quoteFailedTitle": "Quote failed", "quoteFailedDescription": "Unable to get a quote for this transaction. Please try again." } + }, + "earn": { + "stakeAmount": "Stake Amount", + "selectYieldOpportunity": "Select yield opportunity", + "selectYieldFor": "Select yield for %{asset}", + "noYieldsAvailable": "No yield opportunities available for %{asset}", + "estimatedYearlyEarnings": "Est. Yearly Earnings", + "yieldType": "Yield Type", + "confirmEarn": "Confirm Stake", + "earnWith": "Earn with", + "belowMinimum": "Below minimum deposit", + "minimumDeposit": "Minimum Deposit", + "explainers": { + "liquidStakingReceive": "You'll receive %{symbol} which you can trade at any time", + "liquidStakingTrade": "You can trade your liquid staking token at any time", + "liquidStakingWithdraw": "When withdrawing, your assets will be available immediately", + "rewardsSchedule": "Rewards are distributed %{schedule} and accrue automatically", + "stakingUnbonding": "When unstaking, there is a %{days} day unbonding period before tokens are available", + "restakingYield": "Restaking rewards accrue to your position automatically", + "restakingWithdraw": "When withdrawing, your assets may have an unbonding period", + "vaultYield": "Yield accrues to your position automatically", + "vaultWithdraw": "When withdrawing, your assets will be available immediately", + "lendingYield": "Interest accrues to your position automatically", + "lendingWithdraw": "When withdrawing, your assets will be available immediately" + } } } \ No newline at end of file diff --git a/src/components/Layout/Header/Header.tsx b/src/components/Layout/Header/Header.tsx index 712933d3154..c0f10f7a42a 100644 --- a/src/components/Layout/Header/Header.tsx +++ b/src/components/Layout/Header/Header.tsx @@ -56,7 +56,7 @@ const rightHStackSpacingSx = { base: 2, lg: 4 } const searchBoxMaxWSx = { base: 'auto', lg: '400px' } const searchBoxMinWSx = { base: 'auto', xl: '300px' } -const tradeSubMenuItems = [ +const baseTradeSubMenuItems = [ { label: 'navBar.swap', path: '/trade', icon: TbRefresh }, { label: 'limitOrder.heading', path: '/limit', icon: TbLayersSelected }, { label: 'fiatRamps.buy', path: '/ramp/buy', icon: TbCreditCard }, @@ -110,6 +110,15 @@ export const Header = memo(() => { const isActionCenterEnabled = useFeatureFlag('ActionCenter') const isNewWalletManagerEnabled = useFeatureFlag('NewWalletManager') const isRfoxFoxEcosystemPageEnabled = useFeatureFlag('RfoxFoxEcosystemPage') + const isEarnTabEnabled = useFeatureFlag('EarnTab') + + const tradeSubMenuItems = useMemo( + () => + isEarnTabEnabled + ? [...baseTradeSubMenuItems, { label: 'navBar.earn', path: '/earn', icon: TbTrendingUp }] + : baseTradeSubMenuItems, + [isEarnTabEnabled], + ) const { degradedChainIds } = useDiscoverAccounts() const hasWallet = Boolean(walletInfo?.deviceId) diff --git a/src/components/Layout/Header/NavBar/NavigationDropdown.tsx b/src/components/Layout/Header/NavBar/NavigationDropdown.tsx index 1a080746a93..00a393cc2a5 100644 --- a/src/components/Layout/Header/NavBar/NavigationDropdown.tsx +++ b/src/components/Layout/Header/NavBar/NavigationDropdown.tsx @@ -70,7 +70,8 @@ export const NavigationDropdown = ({ label, items, defaultPath }: NavigationDrop return ( currentPath.startsWith('/trade') || currentPath.startsWith('/limit') || - currentPath.startsWith('/ramp') + currentPath.startsWith('/ramp') || + currentPath.startsWith('/earn') ) } diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx new file mode 100644 index 00000000000..46acb7a2e9f --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -0,0 +1,349 @@ +import { Avatar, Box, Button, Flex, HStack, Skeleton, Text, VStack } from '@chakra-ui/react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { SharedConfirm } from '../SharedConfirm/SharedConfirm' +import { EarnRoutePaths } from './types' + +import { Amount } from '@/components/Amount/Amount' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' +import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectMarketDataByFilter, +} from '@/state/slices/selectors' +import { + selectInputSellAmountCryptoPrecision, + selectInputSellAsset, + selectSelectedYieldId, + selectSellAccountId, +} from '@/state/slices/tradeEarnInputSlice/selectors' +import { useAppSelector } from '@/state/store' + +const defaultYieldItem = {} as AugmentedYieldDto + +export const EarnConfirm = memo(() => { + const translate = useTranslate() + const navigate = useNavigate() + + const sellAsset = useAppSelector(selectInputSellAsset) + const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) + const selectedYieldId = useAppSelector(selectSelectedYieldId) + const sellAccountId = useAppSelector(selectSellAccountId) + + const { data: yieldsData, isLoading: isLoadingYields } = useYields() + + const selectedYield = useMemo(() => { + if (!selectedYieldId || !yieldsData?.byId) return undefined + return yieldsData.byId[selectedYieldId] + }, [selectedYieldId, yieldsData?.byId]) + + const hasValidState = Boolean( + selectedYieldId && sellAmountCryptoPrecision && bnOrZero(sellAmountCryptoPrecision).gt(0), + ) + + useEffect(() => { + if (!isLoadingYields && !hasValidState) { + navigate(EarnRoutePaths.Input, { replace: true }) + } + }, [isLoadingYields, hasValidState, navigate]) + + // Fallback to account 0 if no account selected + const yieldChainId = selectedYield?.chainId + const fallbackAccountId = useAppSelector(state => { + if (sellAccountId) return undefined + if (!yieldChainId) return undefined + return selectAccountIdByAccountNumberAndChainId(state)[0]?.[yieldChainId] + }) + const accountIdToUse = sellAccountId ?? fallbackAccountId + + const requiresValidatorSelection = selectedYield?.mechanics.requiresValidatorSelection ?? false + + const { data: validators } = useYieldValidators(selectedYieldId ?? '', requiresValidatorSelection) + const { data: providers } = useYieldProviders() + + const selectedValidatorAddress = useMemo(() => { + if (!requiresValidatorSelection || !validators?.length) return undefined + const chainId = selectedYield?.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (defaultAddress) { + const defaultValidator = validators.find(v => v.address === defaultAddress) + if (defaultValidator) return defaultValidator.address + } + const preferred = validators.find(v => v.preferred) + return preferred?.address ?? validators[0]?.address + }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + + const selectedValidator = useMemo(() => { + if (!selectedValidatorAddress || !validators?.length) return undefined + return validators.find(v => v.address === selectedValidatorAddress) + }, [selectedValidatorAddress, validators]) + + const sellAssetFromState = useAppSelector(state => + selectAssetById(state, sellAsset?.assetId ?? ''), + ) + + const { price: sellAssetUserCurrencyRate } = + useAppSelector(state => selectMarketDataByFilter(state, { assetId: sellAsset?.assetId })) || {} + + const sellAmountUserCurrency = useMemo(() => { + if (!sellAmountCryptoPrecision || !sellAssetUserCurrencyRate) return undefined + return bnOrZero(sellAmountCryptoPrecision).times(sellAssetUserCurrencyRate).toString() + }, [sellAmountCryptoPrecision, sellAssetUserCurrencyRate]) + + const handleBack = useCallback(() => navigate(EarnRoutePaths.Input), [navigate]) + const handleClose = useCallback(() => navigate(EarnRoutePaths.Input), [navigate]) + + const apy = useMemo( + () => (selectedYield ? (selectedYield.rewardRate?.total ?? 0) * 100 : 0), + [selectedYield], + ) + + const estimatedYearlyEarnings = useMemo(() => { + if (!selectedYield || !sellAmountCryptoPrecision) return undefined + const apyDecimal = selectedYield.rewardRate?.total ?? 0 + const amount = bnOrZero(sellAmountCryptoPrecision) + if (amount.isZero()) return undefined + return amount.times(apyDecimal).decimalPlaces(6).toString() + }, [selectedYield, sellAmountCryptoPrecision]) + + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + canSubmit, + handleConfirm, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem: selectedYield ?? defaultYieldItem, + action: 'enter', + amount: sellAmountCryptoPrecision, + assetSymbol: sellAsset?.symbol ?? '', + onClose: handleClose, + isOpen: Boolean(selectedYield), + validatorAddress: selectedValidatorAddress, + accountId: accountIdToUse, + }) + + // Align loading states with YieldEnterModal + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending + const isLoading = isLoadingYields || isQuoteActive + + // Use stepsToShow pattern from YieldEnterModal - show transactionSteps once execution starts + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + const confirmButtonText = useMemo(() => { + // Use the current step's type/title for a clean button label (e.g., "Enter", "Approve") + if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { + const currentStep = transactionSteps[activeStepIndex] + return getTransactionButtonText(currentStep.type, currentStep.originalTitle) + } + // USDT reset required before other transactions + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } + // Before execution starts, use the first CREATED transaction from quoteData + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') + if (firstCreatedTx) { + return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + } + // Fallback states + if (isLoading) return translate('common.loadingText') + return translate('yieldXYZ.enter') + }, [activeStepIndex, transactionSteps, isUsdtResetRequired, quoteData, isLoading, translate]) + + const providerInfo = useMemo(() => { + if (selectedValidator) { + return { name: selectedValidator.name, logoURI: selectedValidator.logoURI } + } + if (selectedYield) { + const provider = providers?.[selectedYield.providerId] + if (provider) { + return { name: provider.name, logoURI: provider.logoURI } + } + return { name: selectedYield.metadata.name, logoURI: selectedYield.metadata.logoURI } + } + return null + }, [selectedValidator, selectedYield, providers]) + + if (!selectedYield) { + return ( + + {translate('earn.selectYieldOpportunity')} + + + } + footerContent={null} + isLoading={false} + onBack={handleBack} + headerTranslation='earn.confirmEarn' + /> + ) + } + + if (step === ModalStep.Success) { + return ( + + + + } + footerContent={null} + isLoading={false} + onBack={handleBack} + headerTranslation='yieldXYZ.success' + /> + ) + } + + const bodyContent = ( + + + + + + + + {translate('common.amount')} + + {isLoading ? ( + + ) : ( + + )} + + + + + {translate('common.apy')} + + {isLoading ? ( + + ) : ( + + {apy.toFixed(2)}% + + )} + + + {estimatedYearlyEarnings && ( + + + {translate('yieldXYZ.estEarnings')} + + {isLoading ? ( + + ) : ( + + + {estimatedYearlyEarnings} {sellAsset?.symbol ?? ''}/yr + + {sellAmountUserCurrency && ( + + + + )} + + )} + + )} + + {providerInfo && ( + + + {selectedValidator + ? translate('yieldXYZ.validator') + : translate('yieldXYZ.provider')} + + + + + {providerInfo.name} + + + + )} + + + {stepsToShow.length > 0 && ( + + + + )} + + + ) + + const footerContent = ( + + + + ) + + return ( + + ) +}) diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx new file mode 100644 index 00000000000..b994d43ca15 --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -0,0 +1,527 @@ +import { Box, Flex, Stack, useMediaQuery } from '@chakra-ui/react' +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { cosmosChainId, ethAssetId, fromAccountId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' +import { isToken } from '@shapeshiftoss/utils' +import { useQuery } from '@tanstack/react-query' +import type { FormEvent } from 'react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import type { SideComponentProps } from '../SharedTradeInput/SharedTradeInput' +import { SharedTradeInput } from '../SharedTradeInput/SharedTradeInput' +import { SellAssetInput } from '../TradeInput/components/SellAssetInput' +import { EarnFooter } from './components/EarnFooter' +import { YieldSelector } from './components/YieldSelector' +import { EarnRoutePaths } from './types' + +import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' +import { FormDivider } from '@/components/FormDivider' +import { TradeInputTab } from '@/components/MultiHopTrade/types' +import { useDebounce } from '@/hooks/useDebounce/useDebounce' +import { useModal } from '@/hooks/useModal/useModal' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero, positiveOrZero } from '@/lib/bignumber/bignumber' +import { enterYield } from '@/lib/yieldxyz/api' +import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { useYields } from '@/react-queries/queries/yieldxyz/useYields' +import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' +import { + selectAccountIdByAccountNumberAndChainId, + selectAssetById, + selectFeeAssetByChainId, + selectMarketDataByAssetIdUserCurrency, + selectMarketDataByFilter, + selectPortfolioCryptoPrecisionBalanceByFilter, +} from '@/state/slices/selectors' +import { + selectEarnHasUserEnteredAmount, + selectInputSellAmountCryptoBaseUnit, + selectInputSellAmountCryptoPrecision, + selectInputSellAmountUserCurrency, + selectInputSellAsset, + selectIsInputtingFiatSellAmount, + selectSelectedYieldId, + selectSellAccountId, +} from '@/state/slices/tradeEarnInputSlice/selectors' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' +import { useAppDispatch, useAppSelector } from '@/state/store' +import { breakpoints } from '@/theme/theme' + +export type EarnInputProps = { + onChangeTab: (newTab: TradeInputTab) => void + tradeInputRef: React.RefObject + defaultSellAssetId?: string + defaultYieldId?: string + defaultSellAmountCryptoBaseUnit?: string +} + +const EmptySideComponent: React.FC = () => null + +export const EarnInput = memo( + ({ + onChangeTab, + tradeInputRef, + defaultSellAssetId, + defaultYieldId, + defaultSellAmountCryptoBaseUnit, + }: EarnInputProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const dispatch = useAppDispatch() + const [isSmallerThanMd] = useMediaQuery(`(max-width: ${breakpoints.md})`, { ssr: false }) + + const { + state: { isConnected, wallet }, + } = useWallet() + + const sellAsset = useAppSelector(selectInputSellAsset) + const sellAccountId = useAppSelector(selectSellAccountId) + const sellAmountCryptoPrecision = useAppSelector(selectInputSellAmountCryptoPrecision) + const sellAmountUserCurrency = useAppSelector(selectInputSellAmountUserCurrency) + const isInputtingFiatSellAmount = useAppSelector(selectIsInputtingFiatSellAmount) + const hasUserEnteredAmount = useAppSelector(selectEarnHasUserEnteredAmount) + const selectedYieldId = useAppSelector(selectSelectedYieldId) + + const { data: yieldsData, isLoading: isLoadingYields } = useYields() + + const ethAsset = useAppSelector(state => selectAssetById(state, ethAssetId)) + const defaultSellAsset = useAppSelector(state => + defaultSellAssetId ? selectAssetById(state, defaultSellAssetId as AssetId) : undefined, + ) + + useEffect(() => { + if (defaultSellAsset && !sellAsset.assetId) { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(defaultSellAsset)) + } else if (!sellAsset.assetId && ethAsset) { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(ethAsset)) + } + }, [sellAsset.assetId, ethAsset, defaultSellAsset, dispatch]) + + // Set default accountId (account 0) on mount if not already set + const sellAssetChainId = sellAsset?.chainId + const defaultAccountId = useAppSelector(state => { + if (!sellAssetChainId) return undefined + return selectAccountIdByAccountNumberAndChainId(state)[0]?.[sellAssetChainId] + }) + + useEffect(() => { + if (!sellAccountId && defaultAccountId) { + dispatch(tradeEarnInput.actions.setSellAccountId(defaultAccountId)) + } + }, [sellAccountId, defaultAccountId, dispatch]) + + useEffect(() => { + if (defaultYieldId && !selectedYieldId && yieldsData?.byId?.[defaultYieldId]) { + dispatch(tradeEarnInput.actions.setSelectedYieldId(defaultYieldId)) + } + }, [defaultYieldId, selectedYieldId, yieldsData?.byId, dispatch]) + + useEffect(() => { + if (defaultSellAmountCryptoBaseUnit && defaultSellAsset && !sellAmountCryptoPrecision) { + const precision = defaultSellAsset.precision ?? 18 + const amountCryptoPrecision = bnOrZero(defaultSellAmountCryptoBaseUnit) + .div(bnOrZero(10).pow(precision)) + .toString() + dispatch(tradeEarnInput.actions.setSellAmountCryptoPrecision(amountCryptoPrecision)) + } + }, [defaultSellAmountCryptoBaseUnit, defaultSellAsset, sellAmountCryptoPrecision, dispatch]) + + const sellAmountCryptoBaseUnit = useAppSelector(selectInputSellAmountCryptoBaseUnit) + + useEffect(() => { + if (!sellAsset.assetId || !selectedYieldId) return + + const encodedYieldId = encodeURIComponent(selectedYieldId) + const baseUnit = sellAmountCryptoBaseUnit ?? '0' + + navigate(`/earn/${sellAsset.assetId}/${encodedYieldId}/${baseUnit}`, { replace: true }) + }, [sellAsset.assetId, selectedYieldId, sellAmountCryptoBaseUnit, navigate]) + + const selectedYield = useMemo(() => { + if (!selectedYieldId || !yieldsData?.byId) return undefined + return yieldsData.byId[selectedYieldId] + }, [selectedYieldId, yieldsData?.byId]) + + const requiresValidatorSelection = useMemo(() => { + return selectedYield?.mechanics.requiresValidatorSelection ?? false + }, [selectedYield?.mechanics.requiresValidatorSelection]) + + const { data: validators } = useYieldValidators( + selectedYieldId ?? '', + requiresValidatorSelection, + ) + + const selectedValidator = useMemo(() => { + if (!requiresValidatorSelection || !validators?.length) return undefined + const chainId = selectedYield?.chainId + const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (defaultAddress) { + return ( + validators.find(v => v.address === defaultAddress) ?? + validators.find(v => v.preferred) ?? + validators[0] + ) + } + return validators.find(v => v.preferred) ?? validators[0] + }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + + const selectedValidatorAddress = selectedValidator?.address + + const yieldChainId = selectedYield?.chainId + + const userAddress = useMemo( + () => (sellAccountId ? fromAccountId(sellAccountId).account : ''), + [sellAccountId], + ) + + const feeAsset = useAppSelector(state => + yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, + ) + + const feeAssetMarketData = useAppSelector(state => + feeAsset?.assetId + ? selectMarketDataByAssetIdUserCurrency(state, feeAsset.assetId) + : undefined, + ) + + const debouncedAmount = useDebounce(sellAmountCryptoPrecision, 500) + + const txArguments = useMemo(() => { + if (!selectedYield || !userAddress || !yieldChainId || !debouncedAmount) return null + if (!bnOrZero(debouncedAmount).gt(0)) return null + + const fields = selectedYield.mechanics.arguments.enter.fields + const fieldNames = new Set(fields.map(field => field.name)) + const args: Record = { amount: debouncedAmount } + + if (fieldNames.has('receiverAddress')) { + args.receiverAddress = userAddress + } + + if (fieldNames.has('validatorAddress') && yieldChainId) { + args.validatorAddress = + selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + } + + if (fieldNames.has('cosmosPubKey') && yieldChainId === cosmosChainId) { + args.cosmosPubKey = userAddress + } + + return args + }, [selectedYield, userAddress, yieldChainId, debouncedAmount, selectedValidatorAddress]) + + const { data: quoteData, isLoading: isQuoteLoading } = useQuery({ + queryKey: ['yieldxyz', 'quote', 'enter', selectedYield?.id, userAddress, txArguments], + queryFn: () => { + if (!txArguments || !userAddress || !selectedYield?.id) throw new Error('Missing arguments') + return enterYield({ + yieldId: selectedYield.id, + address: userAddress, + arguments: txArguments, + }) + }, + enabled: + !!txArguments && + !!wallet && + !!sellAccountId && + !!selectedYield && + isConnected && + bnOrZero(debouncedAmount).gt(0), + staleTime: 30_000, + gcTime: 60_000, + retry: false, + }) + + const networkFeeFiatUserCurrency = useMemo(() => { + if (!quoteData?.transactions?.length || !feeAssetMarketData?.price) { + return undefined + } + + const totalGasCryptoPrecision = quoteData.transactions.reduce((acc, tx) => { + if (!tx.gasEstimate) return acc + try { + const gasData = JSON.parse(tx.gasEstimate) + return acc.plus(bnOrZero(gasData.amount)) + } catch { + return acc + } + }, bnOrZero(0)) + + if (totalGasCryptoPrecision.isZero()) return undefined + return totalGasCryptoPrecision.times(feeAssetMarketData.price).toFixed(2) + }, [quoteData?.transactions, feeAssetMarketData?.price]) + + const { price: sellAssetUserCurrencyRate } = + useAppSelector(state => selectMarketDataByFilter(state, { assetId: sellAsset?.assetId })) || + {} + + const balanceFilter = useMemo( + () => ({ accountId: sellAccountId ?? '', assetId: sellAsset?.assetId ?? '' }), + [sellAccountId, sellAsset?.assetId], + ) + const sellAssetBalanceCryptoPrecision = useAppSelector(state => + isConnected ? selectPortfolioCryptoPrecisionBalanceByFilter(state, balanceFilter) : '0', + ) + + const minDeposit = useMemo( + () => selectedYield?.mechanics.entryLimits.minimum, + [selectedYield?.mechanics.entryLimits.minimum], + ) + + const isBelowMinimum = useMemo(() => { + if (!sellAmountCryptoPrecision || !minDeposit) return false + return ( + bnOrZero(sellAmountCryptoPrecision).gt(0) && + bnOrZero(sellAmountCryptoPrecision).lt(minDeposit) + ) + }, [sellAmountCryptoPrecision, minDeposit]) + + const isInsufficientBalance = useMemo(() => { + if (!sellAmountCryptoPrecision || !sellAssetBalanceCryptoPrecision) return false + return bnOrZero(sellAmountCryptoPrecision).gt(sellAssetBalanceCryptoPrecision) + }, [sellAmountCryptoPrecision, sellAssetBalanceCryptoPrecision]) + + const sellAssetSearch = useModal('sellTradeAssetSearch') + + const availableAssetIds = useMemo(() => { + if (!yieldsData?.byInputAssetId) return new Set() + return new Set(Object.keys(yieldsData.byInputAssetId)) + }, [yieldsData?.byInputAssetId]) + + const assetFilterPredicate = useCallback( + (assetId: AssetId) => availableAssetIds.has(assetId), + [availableAssetIds], + ) + + const chainIdFilterPredicate = useCallback(() => true, []) + + const handleSellAssetClick = useCallback(() => { + sellAssetSearch.open({ + onAssetClick: (asset: Asset) => { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) + }, + title: 'trade.tradeFrom', + assetFilterPredicate, + chainIdFilterPredicate, + }) + }, [assetFilterPredicate, chainIdFilterPredicate, dispatch, sellAssetSearch]) + + const setSellAsset = useCallback( + (asset: Asset) => { + dispatch(tradeEarnInput.actions.setSellAssetWithYieldReset(asset)) + }, + [dispatch], + ) + + const setSellAccountId = useCallback( + (accountId: AccountId) => { + dispatch(tradeEarnInput.actions.setSellAccountId(accountId)) + }, + [dispatch], + ) + + const handleSellAmountChange = useCallback( + (value: string) => { + dispatch( + tradeEarnInput.actions.setSellAmountCryptoPrecision(positiveOrZero(value).toString()), + ) + }, + [dispatch], + ) + + const handleIsInputtingFiatSellAmountChange = useCallback( + (isInputtingFiat: boolean) => { + dispatch(tradeEarnInput.actions.setIsInputtingFiatSellAmount(isInputtingFiat)) + }, + [dispatch], + ) + + const handleYieldSelect = useCallback( + (yieldId: string) => { + dispatch(tradeEarnInput.actions.setSelectedYieldId(yieldId)) + }, + [dispatch], + ) + + const percentOptions = useMemo(() => { + if (!sellAsset?.assetId) return [] + if (!isToken(sellAsset.assetId)) return [] + return [1] + }, [sellAsset?.assetId]) + + const assetSelectButtonProps = useMemo( + () => ({ + maxWidth: isSmallerThanMd ? '100%' : undefined, + }), + [isSmallerThanMd], + ) + + const sellTradeAssetSelect = useMemo( + () => ( + + ), + [ + sellAsset?.assetId, + handleSellAssetClick, + setSellAsset, + assetFilterPredicate, + chainIdFilterPredicate, + isSmallerThanMd, + assetSelectButtonProps, + ], + ) + + const yieldsForAsset = useMemo(() => { + if (!sellAsset?.assetId || !yieldsData?.byInputAssetId) return [] + return yieldsData.byInputAssetId[sellAsset.assetId] ?? [] + }, [sellAsset?.assetId, yieldsData?.byInputAssetId]) + + useEffect(() => { + if (yieldsForAsset.length > 0 && !selectedYieldId) { + const sortedByApy = [...yieldsForAsset].sort( + (a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0), + ) + if (sortedByApy[0]) { + dispatch(tradeEarnInput.actions.setSelectedYieldId(sortedByApy[0].id)) + } + } + }, [yieldsForAsset, selectedYieldId, dispatch]) + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault() + if (!selectedYield || !hasUserEnteredAmount || !isConnected) return + navigate(EarnRoutePaths.Confirm) + }, + [selectedYield, hasUserEnteredAmount, isConnected, navigate], + ) + + const estimatedYearlyEarnings = useMemo(() => { + if (!selectedYield || !sellAmountCryptoPrecision) return undefined + const apy = selectedYield.rewardRate?.total ?? 0 + const amount = bnOrZero(sellAmountCryptoPrecision) + if (amount.isZero()) return undefined + return amount.times(apy).decimalPlaces(6).toString() + }, [selectedYield, sellAmountCryptoPrecision]) + + const estimatedYearlyEarningsUserCurrency = useMemo(() => { + if (!estimatedYearlyEarnings || !sellAssetUserCurrencyRate) return undefined + return bnOrZero(estimatedYearlyEarnings) + .times(sellAssetUserCurrencyRate) + .decimalPlaces(2) + .toString() + }, [estimatedYearlyEarnings, sellAssetUserCurrencyRate]) + + const bodyContent = useMemo( + () => ( + + + + + + + + + + ), + [ + sellAccountId, + sellAsset, + isInputtingFiatSellAmount, + isLoadingYields, + translate, + sellTradeAssetSelect, + percentOptions, + sellAmountCryptoPrecision, + sellAmountUserCurrency, + setSellAccountId, + handleIsInputtingFiatSellAmountChange, + handleSellAmountChange, + selectedYieldId, + yieldsForAsset, + handleYieldSelect, + ], + ) + + const footerContent = useMemo( + () => ( + + ), + [ + selectedYield, + hasUserEnteredAmount, + isLoadingYields, + sellAsset, + estimatedYearlyEarnings, + estimatedYearlyEarningsUserCurrency, + isConnected, + selectedValidator, + isBelowMinimum, + isInsufficientBalance, + networkFeeFiatUserCurrency, + isQuoteLoading, + ], + ) + + return ( + } + isCompact={false} + isLoading={isLoadingYields} + SideComponent={EmptySideComponent} + shouldOpenSideComponent={false} + tradeInputTab={TradeInputTab.Earn} + tradeInputRef={tradeInputRef} + onSubmit={handleSubmit} + /> + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx new file mode 100644 index 00000000000..8e6b5d54a0d --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/components/EarnFooter.tsx @@ -0,0 +1,347 @@ +import { InfoIcon } from '@chakra-ui/icons' +import type { CardFooterProps, FlexProps } from '@chakra-ui/react' +import { + Box, + CardFooter, + Flex, + HStack, + Icon, + Image, + Skeleton, + Text, + VStack, +} from '@chakra-ui/react' +import type { Asset } from '@shapeshiftoss/types' +import { memo, useMemo } from 'react' +import { FaGift } from 'react-icons/fa' +import { MdSwapHoriz } from 'react-icons/md' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { AugmentedYieldDto, ValidatorDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' + +type EarnFooterProps = { + selectedYield: AugmentedYieldDto | undefined + hasUserEnteredAmount: boolean + isLoading: boolean + sellAsset: Asset + estimatedYearlyEarnings: string | undefined + estimatedYearlyEarningsUserCurrency: string | undefined + isConnected: boolean + selectedValidator: ValidatorDto | undefined + isBelowMinimum: boolean + isInsufficientBalance: boolean + networkFeeFiatUserCurrency: string | undefined + isQuoteLoading: boolean +} + +const isStakingType = (type: string | undefined): boolean => + ['native-staking', 'pooled-staking', 'staking'].includes(type ?? '') + +const footerBgProp = { base: 'background.surface.base', md: 'transparent' } +const footerPosition: CardFooterProps['position'] = { base: 'sticky', md: 'static' } + +const statsBoxSx: FlexProps = { + bg: 'background.surface.raised.base', + borderRadius: 'lg', + p: 3, + borderWidth: '1px', + borderColor: 'border.base', +} + +const getActionTextKey = (yieldType: string | undefined): string => { + switch (yieldType) { + case 'native-staking': + case 'pooled-staking': + case 'liquid-staking': + case 'staking': + return 'defi.stake' + case 'vault': + return 'common.deposit' + case 'lending': + return 'common.supply' + default: + return 'common.deposit' + } +} + +type ExplainerItem = { + icon: React.ReactNode + textKey: string +} + +const getYieldExplainers = (selectedYield: AugmentedYieldDto): ExplainerItem[] => { + const yieldType = selectedYield.mechanics.type + const outputTokenSymbol = selectedYield.outputToken?.symbol + + switch (yieldType) { + case 'liquid-staking': + return [ + { + icon: , + textKey: outputTokenSymbol + ? 'earn.explainers.liquidStakingReceive' + : 'earn.explainers.liquidStakingTrade', + }, + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { + icon: , + textKey: 'earn.explainers.liquidStakingWithdraw', + }, + ] + case 'native-staking': + case 'pooled-staking': + case 'staking': + return [ + { + icon: , + textKey: 'earn.explainers.rewardsSchedule', + }, + { icon: , textKey: 'earn.explainers.stakingUnbonding' }, + ] + case 'restaking': + return [ + { + icon: , + textKey: 'earn.explainers.restakingYield', + }, + { icon: , textKey: 'earn.explainers.restakingWithdraw' }, + ] + case 'vault': + return [ + { icon: , textKey: 'earn.explainers.vaultYield' }, + { icon: , textKey: 'earn.explainers.vaultWithdraw' }, + ] + case 'lending': + return [ + { icon: , textKey: 'earn.explainers.lendingYield' }, + { icon: , textKey: 'earn.explainers.lendingWithdraw' }, + ] + default: + return [] + } +} + +export const EarnFooter = memo( + ({ + selectedYield, + hasUserEnteredAmount, + isLoading, + sellAsset, + estimatedYearlyEarnings, + estimatedYearlyEarningsUserCurrency, + isConnected, + selectedValidator, + isBelowMinimum, + isInsufficientBalance, + networkFeeFiatUserCurrency, + isQuoteLoading, + }: EarnFooterProps) => { + const translate = useTranslate() + + const apy = useMemo( + () => (selectedYield ? (selectedYield.rewardRate?.total ?? 0) * 100 : 0), + [selectedYield], + ) + + const apyDisplay = useMemo(() => `${apy.toFixed(2)}%`, [apy]) + + const minDeposit = selectedYield?.mechanics.entryLimits.minimum + const hasMinDeposit = minDeposit && bnOrZero(minDeposit).gt(0) + + const hasValidationError = isBelowMinimum || isInsufficientBalance + const isDisabled = + !hasUserEnteredAmount || !selectedYield || isLoading || !isConnected || hasValidationError + + const buttonText = useMemo(() => { + if (!isConnected) return translate('common.connectWallet') + if (!selectedYield) return translate('earn.selectYieldOpportunity') + if (!hasUserEnteredAmount) return translate('common.enterAmount') + if (isInsufficientBalance) return translate('common.insufficientFunds') + if (isBelowMinimum) return translate('earn.belowMinimum') + return translate(getActionTextKey(selectedYield.mechanics.type)) + }, [ + isConnected, + selectedYield, + hasUserEnteredAmount, + isInsufficientBalance, + isBelowMinimum, + translate, + ]) + + const explainers = useMemo( + () => (selectedYield ? getYieldExplainers(selectedYield) : []), + [selectedYield], + ) + + const rewardSchedule = selectedYield?.mechanics.rewardSchedule + const cooldownDays = useMemo(() => { + const seconds = selectedYield?.mechanics.cooldownPeriod?.seconds + if (!seconds) return undefined + return Math.ceil(seconds / 86400) + }, [selectedYield?.mechanics.cooldownPeriod?.seconds]) + + return ( + + + {selectedYield && isStakingType(selectedYield.mechanics.type) && ( + + + {translate('earn.earnWith')} + + + {selectedValidator?.name + + {selectedValidator?.name ?? selectedYield.metadata.name} + + + + )} + + {selectedYield && hasUserEnteredAmount && ( + + + + {translate('common.apy')} + + {isLoading ? ( + + ) : ( + {apyDisplay} + )} + + + {estimatedYearlyEarnings && ( + + + {translate('earn.estimatedYearlyEarnings')} + + {isLoading ? ( + + ) : ( + + + + + {estimatedYearlyEarningsUserCurrency && ( + + + + )} + + )} + + )} + + {hasMinDeposit && ( + + + {translate('earn.minimumDeposit')} + + + + )} + + {selectedYield.mechanics.type && ( + + + {translate('earn.yieldType')} + + + {selectedYield.mechanics.type.replace(/-/g, ' ')} + + + )} + + + + {translate('trade.networkFee')} + + {isQuoteLoading ? ( + + ) : networkFeeFiatUserCurrency ? ( + + ) : ( + + — + + )} + + + )} + + {selectedYield && explainers.length > 0 && ( + + {explainers.map((explainer, index) => ( + + {explainer.icon} + + {translate(explainer.textKey, { + symbol: selectedYield.outputToken?.symbol ?? sellAsset?.symbol ?? '', + schedule: rewardSchedule ?? '', + days: cooldownDays ?? '', + })} + + + ))} + + )} + + + {buttonText} + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx new file mode 100644 index 00000000000..556f316a57f --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -0,0 +1,296 @@ +import { ChevronDownIcon, SearchIcon } from '@chakra-ui/icons' +import { + Box, + Button, + HStack, + Image, + Input, + InputGroup, + InputLeftElement, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Skeleton, + Text, + useColorModeValue, + useDisclosure, + VStack, +} from '@chakra-ui/react' +import type { Asset } from '@shapeshiftoss/types' +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslate } from 'react-polyglot' + +import type { AugmentedYieldDto, ProviderDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' +import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' + +const chevronDownIcon = +const searchIcon = + +type YieldSelectorProps = { + selectedYieldId: string | undefined + yields: AugmentedYieldDto[] + onYieldSelect: (yieldId: string) => void + isLoading: boolean + sellAsset: Asset +} + +const getProviderInfo = ( + yieldItem: AugmentedYieldDto, + providers: Record | undefined, +): { name: string; logoURI: string | undefined } => { + const provider = providers?.[yieldItem.providerId] + if (provider) { + return { name: provider.name, logoURI: provider.logoURI } + } + return { name: yieldItem.metadata.name, logoURI: yieldItem.metadata.logoURI } +} + +const hoverBg = { bg: 'background.surface.raised.hover' } + +const getYieldTypeName = (type: string): string => { + const typeMap: Record = { + 'native-staking': 'Native Staking', + 'pooled-staking': 'Pooled Staking', + 'liquid-staking': 'Liquid Staking', + staking: 'Staking', + lending: 'Lending', + vault: 'Vault', + restaking: 'Restaking', + } + if (typeMap[type]) return typeMap[type] + return type + .split('-') + .filter((word): word is string => Boolean(word)) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +const YieldItem = memo( + ({ + yieldItem, + isSelected, + onClick, + providers, + }: { + yieldItem: AugmentedYieldDto + isSelected: boolean + onClick: () => void + providers: Record | undefined + }) => { + const selectedBg = useColorModeValue('blue.50', 'whiteAlpha.100') + + const apyDisplay = useMemo(() => { + const apy = (yieldItem.rewardRate?.total ?? 0) * 100 + return `${apy.toFixed(2)}%` + }, [yieldItem.rewardRate?.total]) + + const providerInfo = useMemo( + () => getProviderInfo(yieldItem, providers), + [yieldItem, providers], + ) + + return ( + + + {providerInfo.name} + + + {providerInfo.name} + + + + {apyDisplay} + + + + ) + }, +) + +export const YieldSelector = memo( + ({ selectedYieldId, yields, onYieldSelect, isLoading, sellAsset }: YieldSelectorProps) => { + const translate = useTranslate() + const { isOpen, onOpen, onClose } = useDisclosure() + const borderColor = useColorModeValue('gray.200', 'gray.700') + const [searchQuery, setSearchQuery] = useState('') + const { data: providers } = useYieldProviders() + + const selectedYield = useMemo(() => { + if (!selectedYieldId) return undefined + return yields.find(y => y.id === selectedYieldId) + }, [selectedYieldId, yields]) + + const filteredYields = useMemo(() => { + if (!searchQuery.trim()) return yields + const query = searchQuery.toLowerCase() + return yields.filter( + y => + y.metadata.name.toLowerCase().includes(query) || + y.mechanics.type.toLowerCase().includes(query), + ) + }, [yields, searchQuery]) + + const groupedYields = useMemo(() => { + const groups: Record = {} + for (const yieldItem of filteredYields) { + const type = yieldItem.mechanics.type + if (!groups[type]) groups[type] = [] + groups[type].push(yieldItem) + } + for (const type of Object.keys(groups)) { + groups[type].sort((a, b) => (b.rewardRate?.total ?? 0) - (a.rewardRate?.total ?? 0)) + } + return groups + }, [filteredYields]) + + const handleYieldClick = useCallback( + (yieldId: string) => { + onYieldSelect(yieldId) + setSearchQuery('') + onClose() + }, + [onYieldSelect, onClose], + ) + + const handleClose = useCallback(() => { + setSearchQuery('') + onClose() + }, [onClose]) + + const selectedApyDisplay = useMemo(() => { + if (!selectedYield) return '0.00%' + const apy = (selectedYield.rewardRate?.total ?? 0) * 100 + return `${apy.toFixed(2)}%` + }, [selectedYield]) + + const selectedProviderInfo = useMemo( + () => (selectedYield ? getProviderInfo(selectedYield, providers) : null), + [selectedYield, providers], + ) + + if (isLoading) { + return + } + + if (yields.length === 0) { + return ( + + + {translate('earn.noYieldsAvailable', { asset: sellAsset?.symbol ?? 'asset' })} + + + ) + } + + return ( + <> + + + + + + + {translate('earn.selectYieldFor', { asset: sellAsset?.symbol ?? '' })} + + + + + + {searchIcon} + setSearchQuery(e.target.value)} + variant='filled' + /> + + + {Object.entries(groupedYields).length === 0 ? ( + + {translate('common.noResultsFound')} + + ) : ( + Object.entries(groupedYields).map(([type, typeYields]) => ( + + + {getYieldTypeName(type).toUpperCase()} + + + {typeYields.map(yieldItem => ( + handleYieldClick(yieldItem.id)} + providers={providers} + /> + ))} + + + )) + )} + + + + + + ) + }, +) diff --git a/src/components/MultiHopTrade/components/Earn/types.ts b/src/components/MultiHopTrade/components/Earn/types.ts new file mode 100644 index 00000000000..145e4eaf27e --- /dev/null +++ b/src/components/MultiHopTrade/components/Earn/types.ts @@ -0,0 +1,4 @@ +export enum EarnRoutePaths { + Input = '/earn', + Confirm = '/earn/confirm', +} diff --git a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx index 3aa5e0b2065..694f1b299da 100644 --- a/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx +++ b/src/components/MultiHopTrade/components/SharedTradeInput/SharedTradeInputHeader.tsx @@ -45,6 +45,7 @@ export const SharedTradeInputHeader = ({ const enableLimitOrders = useFeatureFlag('LimitOrders') const enableSwapperFiatRamps = useFeatureFlag('SwapperFiatRamps') + const enableEarnTab = useFeatureFlag('EarnTab') const handleChangeTab = useCallback( (newTab: TradeInputTab) => { @@ -70,6 +71,10 @@ export const SharedTradeInputHeader = ({ handleChangeTab(TradeInputTab.SellFiat) }, [handleChangeTab]) + const handleClickEarn = useCallback(() => { + handleChangeTab(TradeInputTab.Earn) + }, [handleChangeTab]) + return ( )} + {enableEarnTab && !isStandalone && ( + + {translate('navBar.earn')} + + )} {rightContent} @@ -210,6 +226,23 @@ export const SharedTradeInputHeader = ({ {translate('fiatRamps.sell')} )} + {enableEarnTab && !isStandalone && ( + + {translate('navBar.earn')} + + )} diff --git a/src/components/MultiHopTrade/types.ts b/src/components/MultiHopTrade/types.ts index e273e264f5d..cb35d40571d 100644 --- a/src/components/MultiHopTrade/types.ts +++ b/src/components/MultiHopTrade/types.ts @@ -20,4 +20,5 @@ export enum TradeInputTab { LimitOrder = 'limitOrder', BuyFiat = 'buy', SellFiat = 'sell', + Earn = 'earn', } diff --git a/src/config.ts b/src/config.ts index d24f2055e2d..e7557edefff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -236,6 +236,7 @@ const validators = { VITE_FEATURE_APP_RATING: bool({ default: false }), VITE_FEATURE_YIELD_XYZ: bool({ default: false }), VITE_FEATURE_YIELDS_PAGE: bool({ default: false }), + VITE_FEATURE_EARN_TAB: bool({ default: false }), VITE_YIELD_XYZ_API_KEY: str({ default: '' }), VITE_YIELD_XYZ_BASE_URL: url({ default: 'https://api.yield.xyz/v1' }), VITE_FEATURE_YIELD_MULTI_ACCOUNT: bool({ default: false }), diff --git a/src/lib/yieldxyz/types.ts b/src/lib/yieldxyz/types.ts index df7c880d734..be584174067 100644 --- a/src/lib/yieldxyz/types.ts +++ b/src/lib/yieldxyz/types.ts @@ -238,6 +238,7 @@ export type YieldMechanics = { entryLimits: YieldEntryLimits arguments: YieldArguments supportsLedgerWalletApi?: boolean + cooldownPeriod?: { seconds: number } possibleFeeTakingMechanisms?: { depositFee: boolean managementFee: boolean diff --git a/src/pages/Trade/tabs/EarnTab.tsx b/src/pages/Trade/tabs/EarnTab.tsx new file mode 100644 index 00000000000..482931cea10 --- /dev/null +++ b/src/pages/Trade/tabs/EarnTab.tsx @@ -0,0 +1,133 @@ +import { Box, Flex } from '@chakra-ui/react' +import { memo, useCallback, useMemo, useRef } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslate } from 'react-polyglot' +import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router-dom' + +import { Main } from '@/components/Layout/Main' +import { SEO } from '@/components/Layout/Seo' +import { EarnConfirm } from '@/components/MultiHopTrade/components/Earn/EarnConfirm' +import { EarnInput } from '@/components/MultiHopTrade/components/Earn/EarnInput' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' +import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' +import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' +import { TradeInputTab, TradeRoutePaths } from '@/components/MultiHopTrade/types' +import { blurBackgroundSx, gridOverlaySx } from '@/pages/Trade/constants' +import { EARN_ROUTE_ASSET_SPECIFIC } from '@/Routes/RoutesCommon' + +const padding = { base: 0, md: 8 } +const mainPaddingTop = { base: 0, md: '4.5rem' } +const mainMarginTop = { base: 0, md: '-4.5rem' } + +const containerPaddingTop = { base: 0, md: 12 } +const containerPaddingBottom = { base: 0, md: 12 } + +export const EarnTab = memo(function EarnTab() { + const translate = useTranslate() + const methods = useForm({ mode: 'onChange' }) + const navigate = useNavigate() + const location = useLocation() + const tradeInputRef = useRef(null) + + const earnMatch = useMemo( + () => matchPath({ path: EARN_ROUTE_ASSET_SPECIFIC, end: true }, location.pathname), + [location.pathname], + ) + + const params = earnMatch?.params + + const defaultSellAssetId = useMemo( + () => + params?.sellChainId && params.sellAssetSubId + ? `${params.sellChainId}/${params.sellAssetSubId}` + : undefined, + [params?.sellChainId, params?.sellAssetSubId], + ) + + const defaultYieldId = useMemo( + () => (params?.yieldId ? decodeURIComponent(params.yieldId) : undefined), + [params?.yieldId], + ) + + const defaultSellAmountCryptoBaseUnit = useMemo( + () => params?.sellAmountCryptoBaseUnit, + [params?.sellAmountCryptoBaseUnit], + ) + + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + switch (newTab) { + case TradeInputTab.Trade: + navigate(TradeRoutePaths.Input) + break + case TradeInputTab.LimitOrder: + navigate(LimitOrderRoutePaths.Input) + break + case TradeInputTab.BuyFiat: + navigate(FiatRampRoutePaths.Buy) + break + case TradeInputTab.SellFiat: + navigate(FiatRampRoutePaths.Sell) + break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break + default: + break + } + }, + [navigate], + ) + + const title = useMemo(() => { + return translate('navBar.earn') + }, [translate]) + + const earnInputElement = useMemo( + () => ( + + ), + [handleChangeTab, defaultSellAssetId, defaultYieldId, defaultSellAmountCryptoBaseUnit], + ) + + const earnConfirmElement = useMemo(() => , []) + + return ( +
+ + + + + + + + + + + +
+ ) +}) diff --git a/src/pages/Trade/tabs/LimitTab.tsx b/src/pages/Trade/tabs/LimitTab.tsx index ba413bff5f9..1a9c2710b10 100644 --- a/src/pages/Trade/tabs/LimitTab.tsx +++ b/src/pages/Trade/tabs/LimitTab.tsx @@ -6,6 +6,7 @@ import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrder } from '@/components/MultiHopTrade/components/LimitOrder/LimitOrder' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' @@ -68,6 +69,9 @@ export const LimitTab = memo(() => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Trade/tabs/RampTab.tsx b/src/pages/Trade/tabs/RampTab.tsx index e6eef70057d..484fae0cc50 100644 --- a/src/pages/Trade/tabs/RampTab.tsx +++ b/src/pages/Trade/tabs/RampTab.tsx @@ -8,6 +8,7 @@ import { RampErrorBoundary } from '@/components/ErrorBoundary/RampErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' import { FiatRampAction } from '@/components/Modals/FiatRamps/FiatRampsCommon' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampTrade } from '@/components/MultiHopTrade/components/FiatRamps/FiatRampTrade' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' @@ -48,6 +49,9 @@ export const RampTab = () => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Trade/tabs/TradeTab.tsx b/src/pages/Trade/tabs/TradeTab.tsx index a1e4ea6d111..9e6d0f537cd 100644 --- a/src/pages/Trade/tabs/TradeTab.tsx +++ b/src/pages/Trade/tabs/TradeTab.tsx @@ -7,6 +7,7 @@ import { matchPath, Route, Routes, useLocation, useNavigate } from 'react-router import { TradingErrorBoundary } from '@/components/ErrorBoundary' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' +import { EarnRoutePaths } from '@/components/MultiHopTrade/components/Earn/types' import { FiatRampRoutePaths } from '@/components/MultiHopTrade/components/FiatRamps/types' import { LimitOrderRoutePaths } from '@/components/MultiHopTrade/components/LimitOrder/types' import { TopAssetsCarousel } from '@/components/MultiHopTrade/components/TradeInput/components/TopAssetsCarousel' @@ -78,6 +79,9 @@ export const TradeTab = memo(() => { case TradeInputTab.SellFiat: navigate(FiatRampRoutePaths.Sell) break + case TradeInputTab.Earn: + navigate(EarnRoutePaths.Input) + break default: break } diff --git a/src/pages/Yields/components/YieldActionModal.tsx b/src/pages/Yields/components/YieldActionModal.tsx index 9125963bbe1..20cc67088bf 100644 --- a/src/pages/Yields/components/YieldActionModal.tsx +++ b/src/pages/Yields/components/YieldActionModal.tsx @@ -1,8 +1,5 @@ -import { Avatar, Box, Button, Flex, Icon, Text, VStack } from '@chakra-ui/react' -import { keyframes } from '@emotion/react' -import { memo, useEffect, useMemo } from 'react' -import ReactCanvasConfetti from 'react-canvas-confetti' -import { FaCheck, FaWallet } from 'react-icons/fa' +import { Avatar, Box, Button, Flex, Text } from '@chakra-ui/react' +import { memo, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { Amount } from '@/components/Amount/Amount' @@ -14,11 +11,11 @@ import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' -import { useConfetti } from '@/pages/Yields/hooks/useConfetti' -import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' +import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -28,13 +25,6 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' -const walletIcon = -const checkIconBox = ( - - - -) - type YieldActionModalProps = { isOpen: boolean onClose: () => void @@ -48,6 +38,7 @@ type YieldActionModalProps = { validatorLogoURI?: string passthrough?: string manageActionType?: string + accountId?: string } export const YieldActionModal = memo(function YieldActionModal({ @@ -69,6 +60,7 @@ export const YieldActionModal = memo(function YieldActionModal({ const { step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, @@ -88,6 +80,7 @@ export const YieldActionModal = memo(function YieldActionModal({ validatorAddress, passthrough, manageActionType: props.manageActionType, + accountId: props.accountId, }) const shouldFetchValidators = useMemo( @@ -120,19 +113,6 @@ export const YieldActionModal = memo(function YieldActionModal({ const chainId = useMemo(() => yieldItem.chainId ?? '', [yieldItem.chainId]) const feeAsset = useAppSelector(state => selectFeeAssetByChainId(state, chainId)) - const horizontalScroll = useMemo( - () => keyframes` - 0% { background-position: 0 0; } - 100% { background-position: 28px 0; } - `, - [], - ) - - const flexDirection = useMemo( - () => (action === 'enter' ? 'row' : 'row-reverse') as 'row' | 'row-reverse', - [action], - ) - const assetAvatarSrc = useMemo( () => assetLogoURI ?? yieldItem.token.logoURI, [assetLogoURI, yieldItem.token.logoURI], @@ -194,11 +174,15 @@ export const YieldActionModal = memo(function YieldActionModal({ }, [isQuoteLoading, action, translate, activeStepIndex, transactionSteps]) const buttonText = useMemo(() => { - // Use the current step's type/title for a clean button label (e.g., "Delegate", "Undelegate", "Approve") + // Use the current step's type/title for a clean button label (e.g., "Enter", "Exit", "Approve") if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { const step = transactionSteps[activeStepIndex] return getTransactionButtonText(step.type, step.originalTitle) } + // USDT reset required before other transactions + if (isUsdtResetRequired) { + return translate('yieldXYZ.resetAllowance') + } // Before execution starts, use the first CREATED transaction from quoteData const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') if (firstCreatedTx) { @@ -208,7 +192,7 @@ export const YieldActionModal = memo(function YieldActionModal({ if (action === 'enter') return translate('yieldXYZ.enter') if (action === 'exit') return translate('yieldXYZ.exit') return translate('common.claim') - }, [action, translate, activeStepIndex, transactionSteps, quoteData]) + }, [action, translate, activeStepIndex, transactionSteps, quoteData, isUsdtResetRequired]) const modalHeading = useMemo(() => { if (action === 'enter') return translate('yieldXYZ.enterSymbol', { symbol: assetSymbol }) @@ -216,116 +200,24 @@ export const YieldActionModal = memo(function YieldActionModal({ return translate('yieldXYZ.claimSymbol', { symbol: assetSymbol }) }, [action, assetSymbol, translate]) - const successMessage = useMemo(() => { - if (action === 'enter') - return translate('yieldXYZ.successEnter', { symbol: assetSymbol, amount }) - if (action === 'exit') return translate('yieldXYZ.successExit', { symbol: assetSymbol, amount }) - return translate('yieldXYZ.successClaim', { symbol: assetSymbol, amount }) - }, [action, assetSymbol, amount, translate]) - const networkAvatarSrc = useMemo( () => feeAsset?.networkIcon ?? feeAsset?.icon, [feeAsset?.networkIcon, feeAsset?.icon], ) - // Show steps from quoteData before execution starts, then switch to actual transactionSteps - const displaySteps = useMemo((): TransactionStep[] => { - // If we have transactionSteps (execution has started or completed), use those - if (transactionSteps.length > 0) { - return transactionSteps - } - // Don't show preview steps while still checking if USDT reset is needed - if (isAllowanceCheckPending) return [] - // Before execution, create preview steps from quoteData (filter out SKIPPED transactions) - if (quoteData?.transactions?.length) { - const steps: TransactionStep[] = [] - // Add reset step if USDT reset is required - if (isUsdtResetRequired) { - steps.push({ - title: translate('yieldXYZ.resetAllowance'), - originalTitle: 'Reset Allowance', - type: 'RESET', - status: 'pending' as const, - }) - } - // Add yield.xyz transactions - steps.push( - ...quoteData.transactions - .filter(tx => tx.status === 'CREATED') - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })), - ) - return steps - } - return [] - }, [ - transactionSteps, - quoteData, - assetSymbol, - isAllowanceCheckPending, - isUsdtResetRequired, - translate, - ]) + const assetFlowDirection = action === 'exit' ? 'exit' : 'enter' const animatedAvatarRow = useMemo( () => ( - - - - - - - {assetSymbol} - - - - - - - - - - - - {vaultMetadata.name} - - - + ), - [ - flexDirection, - assetAvatarSrc, - assetSymbol, - horizontalScroll, - vaultMetadata.logoURI, - vaultMetadata.name, - ], + [assetSymbol, assetAvatarSrc, vaultMetadata.name, vaultMetadata.logoURI, assetFlowDirection], ) const statsContent = useMemo( @@ -439,114 +331,98 @@ export const YieldActionModal = memo(function YieldActionModal({ [animatedAvatarRow, statsContent, displaySteps], ) - const { getInstance, fireConfetti, confettiStyle } = useConfetti() + const successMessageKey = useMemo(() => { + if (action === 'enter') return 'successEnter' as const + if (action === 'exit') return 'successExit' as const + return 'successClaim' as const + }, [action]) - useEffect(() => { - if (step === ModalStep.Success) fireConfetti() - }, [step, fireConfetti]) + const successProviderInfo = useMemo( + () => (vaultMetadata ? { name: vaultMetadata.name, logoURI: vaultMetadata.logoURI } : null), + [vaultMetadata], + ) const successContent = useMemo( () => ( - - - - - - {successMessage} - - {vaultMetadata && ( - - - - {vaultMetadata.name} - - - )} - - - - + ), - [successMessage, vaultMetadata, transactionSteps], + [ + amount, + assetSymbol, + successProviderInfo, + transactionSteps, + yieldItem.id, + handleClose, + successMessageKey, + ], ) const isInProgress = step === ModalStep.InProgress const isSuccess = step === ModalStep.Success return ( - <> - - - - {null} - - {isSuccess ? translate('common.success') : modalHeading} - - - - - - - {isInProgress && actionContent} - {isSuccess && successContent} - - {isInProgress && ( - - - - )} - {isSuccess && ( - - - - )} - - + + + {null} + + {isSuccess ? translate('common.success') : modalHeading} + + + + + + + {isInProgress && actionContent} + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldAssetFlow.tsx b/src/pages/Yields/components/YieldAssetFlow.tsx new file mode 100644 index 00000000000..9e384e0284a --- /dev/null +++ b/src/pages/Yields/components/YieldAssetFlow.tsx @@ -0,0 +1,76 @@ +import { Avatar, Box, Flex, Text, VStack } from '@chakra-ui/react' +import { keyframes } from '@emotion/react' +import { memo, useMemo } from 'react' + +type YieldAssetFlowProps = { + assetSymbol: string + assetLogoURI: string + providerName: string + providerLogoURI: string | undefined + direction?: 'enter' | 'exit' +} + +export const YieldAssetFlow = memo( + ({ + assetSymbol, + assetLogoURI, + providerName, + providerLogoURI, + direction = 'enter', + }: YieldAssetFlowProps) => { + const horizontalScroll = useMemo( + () => keyframes` + 0% { background-position: 0 0; } + 100% { background-position: 28px 0; } + `, + [], + ) + + const flexDirection = useMemo( + () => (direction === 'enter' ? 'row' : 'row-reverse') as 'row' | 'row-reverse', + [direction], + ) + + return ( + + + + + + + {assetSymbol} + + + + + + + + + + + + {providerName} + + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldEnterModal.tsx b/src/pages/Yields/components/YieldEnterModal.tsx index 4d91a389885..e16dab4cfd6 100644 --- a/src/pages/Yields/components/YieldEnterModal.tsx +++ b/src/pages/Yields/components/YieldEnterModal.tsx @@ -1,30 +1,10 @@ -import { - Avatar, - Box, - Button, - Flex, - Heading, - HStack, - Icon, - Input, - Skeleton, - Text, - useToast, - VStack, -} from '@chakra-ui/react' -import { cosmosChainId, ethChainId, fromAccountId } from '@shapeshiftoss/caip' -import { assertGetViemClient } from '@shapeshiftoss/contracts' -import type { KnownChainIds } from '@shapeshiftoss/types' -import { useQuery, useQueryClient } from '@tanstack/react-query' -import { uuidv4 } from '@walletconnect/utils' +import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' +import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useEffect, useMemo, useState } from 'react' -import ReactCanvasConfetti from 'react-canvas-confetti' -import { FaCheck } from 'react-icons/fa' import { TbSwitchVertical } from 'react-icons/tb' import type { NumberFormatValues } from 'react-number-format' import { NumericFormat } from 'react-number-format' import { useTranslate } from 'react-polyglot' -import type { Hash } from 'viem' import { AccountSelector } from '@/components/AccountSelector/AccountSelector' import { Amount } from '@/components/Amount/Amount' @@ -35,59 +15,33 @@ import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButt import { DialogFooter } from '@/components/Modal/components/DialogFooter' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' -import { SECOND_CLASS_CHAINS } from '@/constants/chains' import { WalletActions } from '@/context/WalletProvider/actions' -import { useDebounce } from '@/hooks/useDebounce/useDebounce' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { enterYield } from '@/lib/yieldxyz/api' import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, } from '@/lib/yieldxyz/constants' -import type { CosmosStakeArgs } from '@/lib/yieldxyz/executeTransaction' -import { executeTransaction } from '@/lib/yieldxyz/executeTransaction' -import type { AugmentedYieldDto, TransactionDto } from '@/lib/yieldxyz/types' -import { TransactionStatus } from '@/lib/yieldxyz/types' -import { formatYieldTxTitle, getTransactionButtonText } from '@/lib/yieldxyz/utils' +import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getTransactionButtonText } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' -import { useConfetti } from '@/pages/Yields/hooks/useConfetti' -import type { TransactionStep } from '@/pages/Yields/hooks/useYieldTransactionFlow' -import { - filterExecutableTransactions, - getSpenderFromApprovalTx, - isApprovalTransaction, - isUsdtOnEthereumMainnet, - waitForActionCompletion, - waitForTransactionConfirmation, -} from '@/pages/Yields/hooks/useYieldTransactionFlow' -import { reactQueries } from '@/react-queries' -import { useAllowance } from '@/react-queries/hooks/useAllowance' -import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' +import { YieldSuccess } from '@/pages/Yields/components/YieldSuccess' +import { ModalStep, useYieldTransactionFlow } from '@/pages/Yields/hooks/useYieldTransactionFlow' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' -import { actionSlice } from '@/state/slices/actionSlice/actionSlice' -import { - ActionStatus, - ActionType, - GenericTransactionDisplayType, -} from '@/state/slices/actionSlice/types' -import { portfolioApi } from '@/state/slices/portfolioSlice/portfolioSlice' -import { selectPortfolioAccountMetadataByAccountId } from '@/state/slices/portfolioSlice/selectors' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAccountIdByAccountNumberAndChainId, selectAssetById, - selectFeeAssetByChainId, selectMarketDataByAssetIdUserCurrency, selectPortfolioAccountIdsByAssetIdFilter, selectPortfolioCryptoPrecisionBalanceByFilter, } from '@/state/slices/selectors' -import { useAppDispatch, useAppSelector } from '@/state/store' +import { useAppSelector } from '@/state/store' type YieldEnterModalProps = { isOpen: boolean @@ -96,7 +50,6 @@ type YieldEnterModalProps = { accountNumber?: number } -const QUOTE_DEBOUNCE_MS = 500 const PRESET_PERCENTAGES = [0.25, 0.5, 0.75, 1] as const const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' @@ -151,37 +104,21 @@ const YieldEnterModalSkeleton = memo(() => ( )) -type ModalStep = 'input' | 'success' - export const YieldEnterModal = memo( ({ isOpen, onClose, yieldItem, accountNumber = 0 }: YieldEnterModalProps) => { - const dispatch = useAppDispatch() const queryClient = useQueryClient() - const toast = useToast() const translate = useTranslate() const { state: walletState, dispatch: walletDispatch } = useWallet() - const wallet = walletState.wallet const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) const isYieldMultiAccountEnabled = useFeatureFlag('YieldMultiAccount') - const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const { number: { localeParts }, } = useLocaleFormatter() - const submitHashMutation = useSubmitYieldTransactionHash() const [cryptoAmount, setCryptoAmount] = useState('') const [isFiat, setIsFiat] = useState(false) const [selectedAccountId, setSelectedAccountId] = useState() - const [modalStep, setModalStep] = useState('input') - const [isSubmitting, setIsSubmitting] = useState(false) - const [transactionSteps, setTransactionSteps] = useState([]) const [selectedPercent, setSelectedPercent] = useState(null) - const [activeStepIndex, setActiveStepIndex] = useState(-1) - const [rawTransactions, setRawTransactions] = useState([]) - const [currentActionId, setCurrentActionId] = useState(null) - const [resetTxHash, setResetTxHash] = useState(null) - - const debouncedAmount = useDebounce(cryptoAmount, QUOTE_DEBOUNCE_MS) const { chainId } = yieldItem const inputToken = yieldItem.inputTokens[0] @@ -245,11 +182,6 @@ export const YieldEnterModal = memo( return providers[yieldItem.providerId] }, [providers, yieldItem.providerId]) - const userAddress = useMemo( - () => (accountId ? fromAccountId(accountId).account : ''), - [accountId], - ) - const inputTokenAsset = useAppSelector(state => selectAssetById(state, inputTokenAssetId ?? '')) const inputTokenBalance = useAppSelector(state => @@ -265,15 +197,6 @@ export const YieldEnterModal = memo( selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId ?? ''), ) - const feeAsset = useAppSelector(state => - chainId ? selectFeeAssetByChainId(state, chainId) : undefined, - ) - - const accountMetadataFilter = useMemo(() => ({ accountId: accountId ?? '' }), [accountId]) - const accountMetadata = useAppSelector(state => - selectPortfolioAccountMetadataByAccountId(state, accountMetadataFilter), - ) - const minDeposit = yieldItem.mechanics?.entryLimits?.minimum const isBelowMinimum = useMemo(() => { @@ -281,97 +204,7 @@ export const YieldEnterModal = memo( return bnOrZero(cryptoAmount).lt(minDeposit) }, [cryptoAmount, minDeposit]) - const txArguments = useMemo(() => { - if (!yieldItem || !userAddress || !chainId || !debouncedAmount) return null - if (!bnOrZero(debouncedAmount).gt(0)) return null - - const fields = yieldItem.mechanics.arguments.enter.fields - const fieldNames = new Set(fields.map(field => field.name)) - const args: Record = { amount: debouncedAmount } - - if (fieldNames.has('receiverAddress')) { - args.receiverAddress = userAddress - } - - if (fieldNames.has('validatorAddress') && chainId) { - args.validatorAddress = - selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] - } - - if (fieldNames.has('cosmosPubKey') && chainId === cosmosChainId) { - args.cosmosPubKey = userAddress - } - - return args - }, [yieldItem, userAddress, chainId, debouncedAmount, selectedValidatorAddress]) - - const { - data: quoteData, - isLoading: isQuoteLoading, - isFetching: isQuoteFetching, - } = useQuery({ - queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id, userAddress, txArguments], - queryFn: () => { - if (!txArguments || !userAddress || !yieldItem.id) throw new Error('Missing arguments') - return enterYield({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) - }, - enabled: - !!txArguments && !!wallet && !!accountId && isOpen && bnOrZero(debouncedAmount).gt(0), - staleTime: 30_000, - gcTime: 60_000, - retry: false, - }) - - const approvalSpender = useMemo(() => { - if (!quoteData?.transactions) return null - const createdTransactions = quoteData.transactions.filter( - tx => tx.status === TransactionStatus.Created, - ) - const approvalTx = createdTransactions.find(isApprovalTransaction) - if (!approvalTx) return null - return getSpenderFromApprovalTx(approvalTx) - }, [quoteData?.transactions]) - - const allowanceQuery = useAllowance({ - assetId: inputTokenAssetId, - spender: approvalSpender ?? undefined, - from: userAddress || undefined, - isDisabled: !approvalSpender || !isUsdtApprovalResetEnabled, - isRefetchEnabled: true, - }) - - const isUsdtResetRequired = useMemo(() => { - if (!isUsdtApprovalResetEnabled) return false - if (!isUsdtOnEthereumMainnet(inputTokenAssetId, chainId)) return false - if (!approvalSpender) return false - if (!allowanceQuery.data) return false - return bnOrZero(allowanceQuery.data).gt(0) - }, [ - isUsdtApprovalResetEnabled, - inputTokenAssetId, - chainId, - approvalSpender, - allowanceQuery.data, - ]) - - // Check if we're waiting for USDT allowance check before we can determine reset requirement - const isAllowanceCheckPending = useMemo(() => { - if (!isUsdtApprovalResetEnabled) return false - if (!isUsdtOnEthereumMainnet(inputTokenAssetId, chainId)) return false - if (!approvalSpender) return false - // If we have an approval spender for USDT but allowance data hasn't loaded yet - return allowanceQuery.data === undefined && !allowanceQuery.isError - }, [ - isUsdtApprovalResetEnabled, - inputTokenAssetId, - chainId, - approvalSpender, - allowanceQuery.data, - allowanceQuery.isError, - ]) - const isLoading = isValidatorsLoading || !inputTokenAsset - const isQuoteActive = isQuoteLoading || isQuoteFetching || isAllowanceCheckPending const fiatAmount = useMemo( () => bnOrZero(cryptoAmount).times(marketData?.price ?? 0), @@ -437,20 +270,13 @@ export const YieldEnterModal = memo( ) const handleModalClose = useCallback(() => { - if (isSubmitting) return setCryptoAmount('') setSelectedPercent(null) setIsFiat(false) setSelectedAccountId(undefined) - setModalStep('input') - setTransactionSteps([]) - setActiveStepIndex(-1) - setRawTransactions([]) - setCurrentActionId(null) - setResetTxHash(null) queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote', 'enter', yieldItem.id] }) onClose() - }, [onClose, isSubmitting, queryClient, yieldItem.id]) + }, [onClose, queryClient, yieldItem.id]) const handleAccountChange = useCallback((newAccountId: string) => { setSelectedAccountId(newAccountId) @@ -458,345 +284,52 @@ export const YieldEnterModal = memo( setSelectedPercent(null) }, []) - const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { - if (chainId !== cosmosChainId) return undefined - if (!inputTokenAsset) return undefined - - const validator = - selectedValidatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] - if (!validator) return undefined - - return { - validator, - amountCryptoBaseUnit: bnOrZero(cryptoAmount) - .times(bnOrZero(10).pow(inputTokenAsset.precision)) - .toFixed(0), - action: 'stake', - } - }, [chainId, selectedValidatorAddress, cryptoAmount, inputTokenAsset]) - - const dispatchNotification = useCallback( - (tx: TransactionDto, txHash: string) => { - if (!chainId || !accountId) return - if (!yieldItem.token.assetId) return - - const isApproval = - tx.type?.toLowerCase() === 'approval' || tx.title?.toLowerCase().includes('approv') - const actionType = isApproval ? ActionType.Approve : ActionType.Deposit - - dispatch( - actionSlice.actions.upsertAction({ - id: uuidv4(), - type: actionType, - status: ActionStatus.Complete, - createdAt: Date.now(), - updatedAt: Date.now(), - transactionMetadata: { - displayType: isApproval - ? GenericTransactionDisplayType.Approve - : GenericTransactionDisplayType.Yield, - txHash, - chainId, - assetId: yieldItem.token.assetId, - accountId, - message: isApproval - ? 'actionCenter.approve.approvalTxComplete' - : 'actionCenter.deposit.complete', - amountCryptoPrecision: cryptoAmount, - contractName: yieldItem.metadata.name, - chainName: yieldItem.network, - }, - }), - ) - }, - [dispatch, chainId, accountId, yieldItem, cryptoAmount], - ) - - const updateStepStatus = useCallback((index: number, updates: Partial) => { - setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) - }, []) - - const executeResetAllowance = useCallback(async () => { - if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { - throw new Error(translate('yieldXYZ.errors.walletNotConnected')) - } - - setIsSubmitting(true) - updateStepStatus(0, { - status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), - }) - - try { - const txHash = await reactQueries.mutations - .approve({ - assetId: inputTokenAssetId, - spender: approvalSpender, - amountCryptoBaseUnit: '0', - accountNumber: accountMetadata?.bip44Params?.accountNumber ?? 0, - wallet, - from: userAddress, - }) - .mutationFn() - - if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) - - setResetTxHash(txHash) - const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(0, { txHash, txUrl, loadingMessage: translate('common.confirming') }) - - const publicClient = assertGetViemClient(ethChainId) - await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }) - - await allowanceQuery.refetch() - updateStepStatus(0, { status: 'success', loadingMessage: undefined }) - setActiveStepIndex(1) - } catch (error) { - toast({ - title: translate('yieldXYZ.errors.transactionFailedTitle'), - description: - error instanceof Error - ? error.message - : translate('yieldXYZ.errors.transactionFailedDescription'), - status: 'error', - duration: 5000, - isClosable: true, - }) - updateStepStatus(0, { status: 'failed', loadingMessage: undefined }) - } finally { - setIsSubmitting(false) - } - }, [ - wallet, + const { + step, + transactionSteps, + displaySteps, + isSubmitting, + activeStepIndex, + handleConfirm, + handleClose: hookHandleClose, + isQuoteLoading, + quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, + } = useYieldTransactionFlow({ + yieldItem, + action: 'enter', + amount: cryptoAmount, + assetSymbol: inputTokenAsset?.symbol ?? '', + onClose: handleModalClose, + isOpen, + validatorAddress: selectedValidatorAddress, accountId, - inputTokenAssetId, - approvalSpender, - userAddress, - accountMetadata?.bip44Params?.accountNumber, - feeAsset?.explorerTxLink, - translate, - updateStepStatus, - toast, - allowanceQuery, - ]) - - const executeSingleTransaction = useCallback( - async ( - tx: TransactionDto, - yieldTxIndex: number, - uiStepIndex: number, - allTransactions: TransactionDto[], - actionId: string, - ) => { - if (!wallet || !accountId || !chainId) { - throw new Error(translate('yieldXYZ.errors.walletNotConnected')) - } - - updateStepStatus(uiStepIndex, { - status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), - }) - setIsSubmitting(true) - - try { - const txHash = await executeTransaction({ - tx, - chainId, - wallet, - accountId, - userAddress, - bip44Params: accountMetadata?.bip44Params, - cosmosStakeArgs: buildCosmosStakeArgs(), - }) - - if (!txHash) throw new Error(translate('yieldXYZ.errors.broadcastFailed')) - - const txUrl = feeAsset?.explorerTxLink ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(uiStepIndex, { - txHash, - txUrl, - loadingMessage: translate('common.confirming'), - }) - - await submitHashMutation.mutateAsync({ - transactionId: tx.id, - hash: txHash, - yieldId: yieldItem.id, - address: userAddress, - }) - - const isLastTransaction = yieldTxIndex + 1 >= allTransactions.length - - if (isLastTransaction) { - await waitForActionCompletion(actionId) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) - - if (chainId && SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { - dispatch( - portfolioApi.endpoints.getAccount.initiate( - { accountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - } - - dispatchNotification(tx, txHash) - updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) - setModalStep('success') - } else { - const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) - const nextTx = confirmedAction.transactions.find( - t => t.status === TransactionStatus.Created && t.stepIndex === yieldTxIndex + 1, - ) - - if (nextTx) { - updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) - setRawTransactions(prev => prev.map((t, i) => (i === yieldTxIndex + 1 ? nextTx : t))) - setActiveStepIndex(uiStepIndex + 1) - } else { - await waitForActionCompletion(actionId) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'allBalances'] }) - await queryClient.refetchQueries({ queryKey: ['yieldxyz', 'yields'] }) - - if (chainId && SECOND_CLASS_CHAINS.includes(chainId as KnownChainIds)) { - dispatch( - portfolioApi.endpoints.getAccount.initiate( - { accountId, upsertOnFetch: true }, - { forceRefetch: true }, - ), - ) - } - - dispatchNotification(tx, txHash) - updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) - setModalStep('success') - } - } - } catch (error) { - toast({ - title: translate('yieldXYZ.errors.transactionFailedTitle'), - description: - error instanceof Error - ? error.message - : translate('yieldXYZ.errors.transactionFailedDescription'), - status: 'error', - duration: 5000, - isClosable: true, - }) - updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) - } finally { - setIsSubmitting(false) - } - }, - [ - wallet, - accountId, - chainId, - userAddress, - accountMetadata?.bip44Params, - feeAsset?.explorerTxLink, - translate, - updateStepStatus, - buildCosmosStakeArgs, - submitHashMutation, - yieldItem.id, - queryClient, - dispatchNotification, - dispatch, - toast, - ], - ) - - const handleExecute = useCallback(async () => { - // Handle USDT reset step if required and not yet done - const shouldExecuteReset = isUsdtResetRequired && activeStepIndex === 0 && !resetTxHash - - if (shouldExecuteReset) { - await executeResetAllowance() - return - } - - // Calculate the yield transaction index (offset by 1 if we had a reset step) - // Use resetTxHash as indicator, not isUsdtResetRequired (which changes to false after reset) - const hadResetStep = Boolean(resetTxHash) - const yieldStepIndex = hadResetStep ? activeStepIndex - 1 : activeStepIndex - - // If we're in the middle of a multi-step flow, execute the next step - const hasYieldTx = yieldStepIndex >= 0 && rawTransactions[yieldStepIndex] && currentActionId - - if (hasYieldTx) { - await executeSingleTransaction( - rawTransactions[yieldStepIndex], - yieldStepIndex, - activeStepIndex, - rawTransactions, - currentActionId, - ) - return - } - - // Initial execution - set up and execute first transaction - if (!wallet || !accountId || !chainId || !quoteData || !inputTokenAsset) return + }) - const transactions = filterExecutableTransactions(quoteData.transactions) + const isQuoteActive = isQuoteLoading || isAllowanceCheckPending - if (transactions.length === 0) { - setModalStep('success') - return + useEffect(() => { + if (step === ModalStep.Success) { + handleModalClose() } + }, [step, handleModalClose]) - setCurrentActionId(quoteData.id) - setRawTransactions(transactions) - - // Build transaction steps with reset step if needed - const steps: TransactionStep[] = [] - - if (isUsdtResetRequired) { - steps.push({ - title: translate('yieldXYZ.resetAllowance'), - originalTitle: 'Reset Allowance', - type: 'RESET', - status: 'pending', - }) + const successProviderInfo = useMemo(() => { + if (isStaking && selectedValidatorMetadata) { + return { + name: selectedValidatorMetadata.name, + logoURI: selectedValidatorMetadata.logoURI, + } } - - steps.push( - ...transactions.map((tx, i) => ({ - title: formatYieldTxTitle( - tx.title || translate('yieldXYZ.transactionNumber', { number: i + 1 }), - inputTokenAsset.symbol, - ), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })), - ) - - setTransactionSteps(steps) - setActiveStepIndex(0) - - // Execute first step (reset if required, otherwise first yield tx) - if (isUsdtResetRequired) { - await executeResetAllowance() - } else { - await executeSingleTransaction(transactions[0], 0, 0, transactions, quoteData.id) + if (providerMetadata) { + return { + name: providerMetadata.name, + logoURI: providerMetadata.logoURI, + } } - }, [ - isUsdtResetRequired, - activeStepIndex, - resetTxHash, - executeResetAllowance, - rawTransactions, - currentActionId, - wallet, - accountId, - chainId, - quoteData, - inputTokenAsset, - translate, - executeSingleTransaction, - ]) + return null + }, [isStaking, selectedValidatorMetadata, providerMetadata]) const enterButtonDisabled = useMemo( () => @@ -809,29 +342,23 @@ export const YieldEnterModal = memo( if (!isConnected) return translate('common.connectWallet') if (isQuoteActive) return translate('yieldXYZ.loadingQuote') - // During execution, show the current step's action if (isSubmitting && transactionSteps.length > 0) { const activeStep = transactionSteps.find(s => s.status !== 'success') if (activeStep) return getTransactionButtonText(activeStep.type, activeStep.originalTitle) } - // In multi-step flow (waiting for next click) if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]) { const currentStep = transactionSteps[activeStepIndex] return getTransactionButtonText(currentStep.type, currentStep.originalTitle) } - // Before execution - show reset if required, otherwise first yield tx if (isUsdtResetRequired) { return translate('yieldXYZ.resetAllowance') } - const firstCreatedTx = quoteData?.transactions?.find( - tx => tx.status === TransactionStatus.Created, - ) + const firstCreatedTx = quoteData?.transactions?.find(tx => tx.status === 'CREATED') if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) - // Fallback to generic enter text return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) }, [ isConnected, @@ -846,39 +373,9 @@ export const YieldEnterModal = memo( ]) const modalTitle = useMemo(() => { - if (modalStep === 'success') return translate('common.success') + if (step === ModalStep.Success) return translate('common.success') return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) - }, [translate, inputTokenAsset?.symbol, modalStep]) - - const previewSteps = useMemo((): TransactionStep[] => { - if (!quoteData?.transactions?.length || !inputTokenAsset) return [] - // Don't show preview steps while still checking if USDT reset is needed - if (isAllowanceCheckPending) return [] - - const steps: TransactionStep[] = [] - - if (isUsdtResetRequired) { - steps.push({ - title: translate('yieldXYZ.resetAllowance'), - originalTitle: 'Reset Allowance', - type: 'RESET', - status: 'pending', - }) - } - - steps.push( - ...quoteData.transactions - .filter(tx => tx.status === TransactionStatus.Created) - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, inputTokenAsset.symbol), - originalTitle: tx.title || '', - type: tx.type, - status: 'pending' as const, - })), - ) - - return steps - }, [quoteData, inputTokenAsset, isUsdtResetRequired, isAllowanceCheckPending, translate]) + }, [translate, inputTokenAsset?.symbol, step]) const percentButtons = useMemo( () => ( @@ -1049,156 +546,108 @@ export const YieldEnterModal = memo( fiatAmount, ]) - const { getInstance, fireConfetti, confettiStyle } = useConfetti() - - useEffect(() => { - if (modalStep === 'success') fireConfetti() - }, [modalStep, fireConfetti]) - - const successProviderInfo = useMemo(() => { - if (isStaking && selectedValidatorMetadata) { - return { - name: selectedValidatorMetadata.name, - logoURI: selectedValidatorMetadata.logoURI, - } - } - if (providerMetadata) { - return { - name: providerMetadata.name, - logoURI: providerMetadata.logoURI, - } - } - return null - }, [isStaking, selectedValidatorMetadata, providerMetadata]) + const isInProgress = step === ModalStep.InProgress + const isSuccess = step === ModalStep.Success const successContent = useMemo( () => ( - - - - - - - {translate('yieldXYZ.success')} - - - {translate('yieldXYZ.successEnter', { - amount: cryptoAmount, - symbol: inputTokenAsset?.symbol, - })} - - - {successProviderInfo && ( - - - - {successProviderInfo.name} - - - )} - - - - + ), - [translate, cryptoAmount, inputTokenAsset?.symbol, successProviderInfo, transactionSteps], + [ + cryptoAmount, + inputTokenAsset?.symbol, + successProviderInfo, + transactionSteps, + yieldItem.id, + hookHandleClose, + ], + ) + + const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + + const dialogOnClose = useMemo( + () => (isSubmitting ? () => {} : hookHandleClose), + [isSubmitting, hookHandleClose], ) return ( - <> - - - - {null} - - {modalTitle} - - - - - - - {modalStep === 'input' && ( - - {inputContent} - {percentButtons} - {inputTokenAssetId && accountId && ( - - - - )} - {statsContent} - {activeStepIndex >= 0 ? ( - - ) : ( - previewSteps.length > 0 && - )} - - )} - {modalStep === 'success' && successContent} - - {modalStep === 'input' && ( - - - - )} - {modalStep === 'success' && ( - - - + + + {null} + + {modalTitle} + + + + + + + {isInProgress && ( + + {inputContent} + {percentButtons} + {inputTokenAssetId && accountId && ( + + + + )} + {statsContent} + {stepsToShow.length > 0 && } + )} - - + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }, ) diff --git a/src/pages/Yields/components/YieldSuccess.tsx b/src/pages/Yields/components/YieldSuccess.tsx new file mode 100644 index 00000000000..588d1469551 --- /dev/null +++ b/src/pages/Yields/components/YieldSuccess.tsx @@ -0,0 +1,139 @@ +import { Avatar, Box, Button, Flex, Heading, Icon, Text, VStack } from '@chakra-ui/react' +import { memo, useCallback, useEffect, useMemo } from 'react' +import ReactCanvasConfetti from 'react-canvas-confetti' +import { FaCheck } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { useConfetti } from '../hooks/useConfetti' +import type { TransactionStep } from '../hooks/useYieldTransactionFlow' +import { TransactionStepsList } from './TransactionStepsList' + +type ProviderInfo = { + name: string + logoURI: string | undefined +} + +type YieldSuccessProps = { + amount: string + symbol: string + providerInfo: ProviderInfo | null + transactionSteps: TransactionStep[] + yieldId?: string + onDone: () => void + showConfetti?: boolean + successMessageKey?: 'successEnter' | 'successExit' | 'successClaim' +} + +export const YieldSuccess = memo( + ({ + amount, + symbol, + providerInfo, + transactionSteps, + yieldId, + onDone, + showConfetti = true, + successMessageKey = 'successEnter', + }: YieldSuccessProps) => { + const translate = useTranslate() + const navigate = useNavigate() + const { getInstance, fireConfetti, confettiStyle } = useConfetti() + + useEffect(() => { + if (showConfetti) fireConfetti() + }, [showConfetti, fireConfetti]) + + const handleViewPosition = useCallback(() => { + if (!yieldId) return + navigate(`/yields/${yieldId}`) + }, [yieldId, navigate]) + + const providerPillProps = useMemo( + () => + yieldId + ? { + cursor: 'pointer' as const, + onClick: handleViewPosition, + _hover: { bg: 'background.surface.raised.hover' }, + transition: 'background 0.2s', + } + : {}, + [yieldId, handleViewPosition], + ) + + return ( + <> + + + + + + + + + {translate('yieldXYZ.success')} + + + {translate(`yieldXYZ.${successMessageKey}`, { amount, symbol })} + + + + {providerInfo && ( + + + {translate('yieldXYZ.via')} + + + + {providerInfo.name} + + + )} + + {transactionSteps.length > 0 && ( + + + + )} + + + {yieldId && ( + + )} + + + + + ) + }, +) diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index c4c3973948a..801ee375854 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -145,6 +145,7 @@ type UseYieldTransactionFlowProps = { validatorAddress?: string passthrough?: string manageActionType?: string + accountId?: string } export const useYieldTransactionFlow = ({ @@ -157,6 +158,7 @@ export const useYieldTransactionFlow = ({ validatorAddress, passthrough, manageActionType, + accountId: accountIdProp, }: UseYieldTransactionFlowProps) => { const dispatch = useAppDispatch() const queryClient = useQueryClient() @@ -177,19 +179,19 @@ export const useYieldTransactionFlow = ({ const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const submitHashMutation = useSubmitYieldTransactionHash() - const inputTokenAssetId = useMemo( - () => yieldItem.inputTokens[0]?.assetId, - [yieldItem.inputTokens], - ) + const inputTokenAssetId = useMemo(() => yieldItem.inputTokens?.[0]?.assetId, [yieldItem]) const { chainId: yieldChainId } = yieldItem - const { accountNumber } = useYieldAccount() + const { accountNumber: contextAccountNumber } = useYieldAccount() - const accountId = useAppSelector(state => { + const derivedAccountId = useAppSelector(state => { + if (accountIdProp) return undefined if (!yieldChainId) return undefined - return selectAccountIdByAccountNumberAndChainId(state)[accountNumber]?.[yieldChainId] + return selectAccountIdByAccountNumberAndChainId(state)[contextAccountNumber]?.[yieldChainId] }) + const accountId = accountIdProp ?? derivedAccountId + const feeAsset = useAppSelector(state => yieldChainId ? selectFeeAssetByChainId(state, yieldChainId) : undefined, ) @@ -268,7 +270,7 @@ export const useYieldTransactionFlow = ({ return fn({ yieldId: yieldItem.id, address: userAddress, arguments: txArguments }) }, enabled: !!txArguments && !!wallet && !!accountId && canSubmit && isOpen, - staleTime: Infinity, + staleTime: 0, gcTime: 0, retry: false, }) @@ -327,6 +329,43 @@ export const useYieldTransactionFlow = ({ allowanceQuery.isError, ]) + const displaySteps = useMemo((): TransactionStep[] => { + if (transactionSteps.length > 0) { + return transactionSteps + } + if (isAllowanceCheckPending) return [] + if (quoteData?.transactions?.length) { + const steps: TransactionStep[] = [] + if (isUsdtResetRequired) { + steps.push({ + title: translate('yieldXYZ.resetAllowance'), + originalTitle: 'Reset Allowance', + type: 'RESET', + status: 'pending' as const, + }) + } + steps.push( + ...quoteData.transactions + .filter(tx => tx.status === TransactionStatus.Created) + .map((tx, i) => ({ + title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), + originalTitle: tx.title || '', + type: tx.type, + status: 'pending' as const, + })), + ) + return steps + } + return [] + }, [ + transactionSteps, + quoteData, + assetSymbol, + isAllowanceCheckPending, + isUsdtResetRequired, + translate, + ]) + const updateStepStatus = useCallback((index: number, updates: Partial) => { setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) }, []) @@ -419,21 +458,15 @@ export const useYieldTransactionFlow = ({ const validator = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] if (!validator) return undefined - const inputTokenDecimals = yieldItem.inputTokens[0]?.decimals ?? yieldItem.token.decimals + const inputTokenDecimals = + yieldItem.inputTokens?.[0]?.decimals ?? yieldItem.token?.decimals ?? 18 return { validator, amountCryptoBaseUnit: bnOrZero(amount).times(bnOrZero(10).pow(inputTokenDecimals)).toFixed(0), action: action === 'enter' ? 'stake' : action === 'exit' ? 'unstake' : 'claim', } - }, [ - yieldChainId, - validatorAddress, - amount, - yieldItem.inputTokens, - yieldItem.token.decimals, - action, - ]) + }, [yieldChainId, validatorAddress, amount, yieldItem, action]) const executeResetAllowance = useCallback(async () => { if (!wallet || !accountId || !inputTokenAssetId || !approvalSpender) { @@ -443,7 +476,6 @@ export const useYieldTransactionFlow = ({ setIsSubmitting(true) updateStepStatus(0, { status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) try { @@ -452,7 +484,7 @@ export const useYieldTransactionFlow = ({ assetId: inputTokenAssetId, spender: approvalSpender, amountCryptoBaseUnit: '0', - accountNumber, + accountNumber: accountMetadata?.bip44Params?.accountNumber ?? 0, wallet, from: userAddress, }) @@ -491,7 +523,7 @@ export const useYieldTransactionFlow = ({ accountId, inputTokenAssetId, approvalSpender, - accountNumber, + accountMetadata?.bip44Params?.accountNumber, userAddress, feeAsset?.explorerTxLink, translate, @@ -514,7 +546,6 @@ export const useYieldTransactionFlow = ({ updateStepStatus(uiStepIndex, { status: 'loading', - loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) setIsSubmitting(true) @@ -566,6 +597,7 @@ export const useYieldTransactionFlow = ({ } dispatchNotification(tx, txHash) updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.Success) } else { const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) @@ -595,6 +627,7 @@ export const useYieldTransactionFlow = ({ } dispatchNotification(tx, txHash) updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) + queryClient.removeQueries({ queryKey: ['yieldxyz', 'quote'] }) setStep(ModalStep.Success) } } @@ -776,6 +809,7 @@ export const useYieldTransactionFlow = ({ () => ({ step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, @@ -789,6 +823,7 @@ export const useYieldTransactionFlow = ({ [ step, transactionSteps, + displaySteps, isSubmitting, activeStepIndex, canSubmit, diff --git a/src/state/reducer.ts b/src/state/reducer.ts index ffeaf6cc684..363a1080d99 100644 --- a/src/state/reducer.ts +++ b/src/state/reducer.ts @@ -49,6 +49,7 @@ import { txHistory, txHistoryApi } from './slices/txHistorySlice/txHistorySlice' import { gridplusSlice } from '@/state/slices/gridplusSlice/gridplusSlice' import type { GridPlusState } from '@/state/slices/gridplusSlice/types' +import { tradeEarnInput } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' import { tradeQuoteSlice } from '@/state/slices/tradeQuoteSlice/tradeQuoteSlice' import { tradeRampInput } from '@/state/slices/tradeRampInputSlice/tradeRampInputSlice' @@ -62,6 +63,7 @@ export const slices = { tradeInput, limitOrderInput, tradeRampInput, + tradeEarnInput, tradeQuote: tradeQuoteSlice, limitOrder: limitOrderSlice, snapshot, @@ -166,6 +168,7 @@ export const sliceReducers = { tradeInput: tradeInput.reducer, limitOrderInput: limitOrderInput.reducer, tradeRampInput: tradeRampInput.reducer, + tradeEarnInput: tradeEarnInput.reducer, opportunities: persistReducer( opportunitiesPersistConfig, opportunities.reducer, diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 3577b5a0669..41d2c8f3c8b 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -113,6 +113,7 @@ export type FeatureFlags = { YieldXyz: boolean YieldsPage: boolean YieldMultiAccount: boolean + EarnTab: boolean } export type Flag = keyof FeatureFlags @@ -260,6 +261,7 @@ const initialState: Preferences = { YieldXyz: getConfig().VITE_FEATURE_YIELD_XYZ, YieldsPage: getConfig().VITE_FEATURE_YIELDS_PAGE, YieldMultiAccount: getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT, + EarnTab: getConfig().VITE_FEATURE_EARN_TAB, }, selectedLocale: simpleLocale(), hasWalletSeenTcyClaimAlert: {}, diff --git a/src/state/slices/tradeEarnInputSlice/selectors.ts b/src/state/slices/tradeEarnInputSlice/selectors.ts new file mode 100644 index 00000000000..31c23613557 --- /dev/null +++ b/src/state/slices/tradeEarnInputSlice/selectors.ts @@ -0,0 +1,41 @@ +import { createSelector } from '@reduxjs/toolkit' + +import { createTradeInputBaseSelectors } from '../common/tradeInputBase/createTradeInputBaseSelectors' + +import { bnOrZero } from '@/lib/bignumber/bignumber' +import type { TradeEarnInputState } from '@/state/slices/tradeEarnInputSlice/tradeEarnInputSlice' + +export const { + selectInputBuyAsset, + selectInputSellAsset, + selectInputBuyAssetUserCurrencyRate, + selectInputSellAssetUserCurrencyRate, + selectSellAccountId, + selectBuyAccountId, + selectInputSellAmountCryptoBaseUnit, + selectManualReceiveAddress, + selectIsManualReceiveAddressValidating, + selectIsManualReceiveAddressEditing, + selectIsManualReceiveAddressValid, + selectInputSellAmountUsd, + selectInputSellAmountUserCurrency, + selectSellAssetBalanceCryptoBaseUnit, + selectIsInputtingFiatSellAmount, + selectInputSellAmountCryptoPrecision, + selectSelectedSellAssetChainId, + selectSelectedBuyAssetChainId, + selectHasUserEnteredAmount, + ...privateSelectors +} = createTradeInputBaseSelectors('tradeEarnInput') + +const { selectBaseSlice } = privateSelectors + +export const selectSelectedYieldId = createSelector( + selectBaseSlice, + tradeEarnInput => tradeEarnInput.selectedYieldId, +) + +export const selectEarnHasUserEnteredAmount = createSelector( + selectInputSellAmountCryptoPrecision, + sellAmountCryptoPrecision => bnOrZero(sellAmountCryptoPrecision).gt(0), +) diff --git a/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts new file mode 100644 index 00000000000..8d471081070 --- /dev/null +++ b/src/state/slices/tradeEarnInputSlice/tradeEarnInputSlice.ts @@ -0,0 +1,57 @@ +import type { PayloadAction } from '@reduxjs/toolkit' +import type { AccountId, ChainId } from '@shapeshiftoss/caip' +import type { Asset } from '@shapeshiftoss/types' + +import { defaultAsset } from '../assetsSlice/assetsSlice' +import type { BaseReducers } from '../common/tradeInputBase/createTradeInputBaseSlice' +import { createTradeInputBaseSlice } from '../common/tradeInputBase/createTradeInputBaseSlice' + +export type TradeEarnInputState = { + buyAsset: Asset + sellAsset: Asset + sellAccountId: AccountId | undefined + buyAccountId: AccountId | undefined + sellAmountCryptoPrecision: string + isInputtingFiatSellAmount: boolean + manualReceiveAddress: string | undefined + isManualReceiveAddressValidating: boolean + isManualReceiveAddressEditing: boolean + isManualReceiveAddressValid: boolean | undefined + selectedSellAssetChainId: ChainId | 'All' + selectedBuyAssetChainId: ChainId | 'All' + selectedYieldId: string | undefined +} + +const initialState: TradeEarnInputState = { + buyAsset: defaultAsset, + sellAsset: defaultAsset, + sellAccountId: undefined, + buyAccountId: undefined, + sellAmountCryptoPrecision: '', + isInputtingFiatSellAmount: false, + manualReceiveAddress: undefined, + isManualReceiveAddressValidating: false, + isManualReceiveAddressValid: undefined, + isManualReceiveAddressEditing: false, + selectedSellAssetChainId: 'All', + selectedBuyAssetChainId: 'All', + selectedYieldId: undefined, +} + +export const tradeEarnInput = createTradeInputBaseSlice({ + name: 'tradeEarnInput', + initialState, + extraReducers: (baseReducers: BaseReducers) => ({ + setSelectedYieldId: (state: TradeEarnInputState, action: PayloadAction) => { + state.selectedYieldId = action.payload + }, + setSellAssetWithYieldReset: (state: TradeEarnInputState, action: PayloadAction) => { + baseReducers.setSellAsset(state, action) + state.selectedYieldId = undefined + state.isInputtingFiatSellAmount = false + }, + }), + selectors: { + selectSelectedYieldId: state => state.selectedYieldId, + }, +}) diff --git a/src/state/store.ts b/src/state/store.ts index 7b17f864839..b3e7cf0615d 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -53,6 +53,7 @@ export const clearState = () => { store.dispatch(slices.tradeInput.actions.clear()) store.dispatch(slices.localWallet.actions.clear()) store.dispatch(slices.limitOrderInput.actions.clear()) + store.dispatch(slices.tradeEarnInput.actions.clear()) store.dispatch(slices.limitOrder.actions.clear()) store.dispatch(slices.gridplus.actions.clear()) store.dispatch(slices.addressBook.actions.clear()) diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index bec51689460..3d06288f098 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -186,6 +186,7 @@ export const mockStore: ReduxState = { YieldXyz: false, YieldsPage: false, YieldMultiAccount: false, + EarnTab: false, }, showTopAssetsCarousel: true, quickBuyAmounts: [10, 50, 100], @@ -348,6 +349,21 @@ export const mockStore: ReduxState = { selectedBuyFiatRampQuote: null, selectedSellFiatRampQuote: null, }, + tradeEarnInput: { + buyAsset: defaultAsset, + sellAsset: defaultAsset, + sellAccountId: undefined, + buyAccountId: undefined, + sellAmountCryptoPrecision: '0', + isInputtingFiatSellAmount: false, + manualReceiveAddress: undefined, + isManualReceiveAddressValidating: false, + isManualReceiveAddressEditing: false, + isManualReceiveAddressValid: undefined, + selectedBuyAssetChainId: 'All', + selectedSellAssetChainId: 'All', + selectedYieldId: undefined, + }, tradeQuote: { activeQuoteMeta: undefined, confirmedQuote: undefined,