diff --git a/.env b/.env index e96408c6644..c10f0c95182 100644 --- a/.env +++ b/.env @@ -309,5 +309,6 @@ VITE_FEATURE_KATANA=true # Yield.xyz Feature Flag 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 diff --git a/.env.development b/.env.development index 2c0d8104231..869fc4c9098 100644 --- a/.env.development +++ b/.env.development @@ -97,3 +97,4 @@ VITE_FEATURE_AVNU_SWAP=true VITE_FEATURE_NEAR=true VITE_FEATURE_KATANA=true VITE_FEATURE_YIELD_XYZ=true +VITE_FEATURE_YIELDS_PAGE=true diff --git a/src/Routes/RoutesCommon.tsx b/src/Routes/RoutesCommon.tsx index a3af4334438..341d58e45a4 100644 --- a/src/Routes/RoutesCommon.tsx +++ b/src/Routes/RoutesCommon.tsx @@ -246,7 +246,7 @@ export const routes: Route[] = [ category: RouteCategory.Featured, priority: 3, mobileNav: false, - disable: !getConfig().VITE_FEATURE_YIELD_XYZ, + disable: !getConfig().VITE_FEATURE_YIELD_XYZ || !getConfig().VITE_FEATURE_YIELDS_PAGE, }, { path: '/ramp/*', diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 044c7b0d22e..115ede0c8de 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2671,6 +2671,7 @@ "pageSubtitle": "Discover and manage yield opportunities across multiple chains", "enter": "Enter", "exit": "Exit", + "enterAsset": "Enter %{asset}", "yield": "Yield", "apy": "APY", "apr": "APR", @@ -2682,49 +2683,24 @@ "noYields": "No yield opportunities available", "connectWallet": "Connect a wallet to view yields", "stats": "Stats", - "minDeposit": "Min Deposit", - "mechanics": "Mechanics", + "minEnter": "Min Enter", "rewardSchedule": "Reward Schedule", "gasToken": "Gas Token", - "transactionSteps": "Transaction Steps", - "stepApprove": "Approve", - "stepApproveDesc": "Approve the token for deposit", - "stepDeposit": "Deposit", - "stepDepositDesc": "Deposit your assets into the strategy", - "stepComplete": "Complete", - "stepCompleteDesc": "Your deposit is complete and earning yield", - "gasFeeNote": "Gas fees are paid in the native token of the network", - "yourInfo": "Your Position", - "activeBalance": "Active Balance", - "entering": "Entering", + "entering": "Entering...", + "exiting": "Exiting...", "withdrawable": "Withdrawable", - "locked": "Locked", - "enterDisabled": "Enter is currently disabled for this yield opportunity", - "exitDisabled": "Exit is currently disabled for this yield opportunity", "type": "Type", - "inputToken": "Input Token", - "netApy": "Net APY", - "grossApy": "Gross APY", "totalValue": "Total Value", "myPosition": "My Position", + "myPositions": "My Positions", "myValidatorPosition": "My %{validator} Position", - "vault": "Vault", - "lending": "Lending", - "yourDeposits": "Your Deposits", - "positions": "Positions", - "opportunities": "Opportunities", - "yields": "Yields", - "earnUpTo": "You could earn up to %{apy}% on your balance", + "earnUpToOnBalance": "You could earn up to %{apy} on your balance", "startEarning": "Start earning", "maxApy": "Max APY", "validator": "Validator", - "validatorBreakdown": "Validator Breakdown", - "staked": "Staked", - "exiting": "Exiting", + "entered": "Entered", "claimable": "Claimable", "loadingQuote": "Loading Quote...", - "depositing": "Depositing...", - "withdrawing": "Withdrawing...", "selectValidator": "Select Validator", "allValidators": "All Validators", "myValidators": "My Validators", @@ -2742,17 +2718,16 @@ "allProviders": "All Providers", "showAll": "Show All", "searchValidator": "Search for validator", - "depositYourToken": "Deposit your %{symbol} to start earning yield securely.", + "enterYourToken": "Enter your %{symbol} to start earning yield securely.", "noActiveValidators": "You don't have any active validators yet.", - "confirming": "Confirming...", "success": "Success!", "transactions": "Transactions", "currentApy": "Current APY", "estYearlyEarnings": "Est. Yearly Earnings", "allPositions": "All Positions", "switch": "Switch", - "supplySymbol": "Supply %{symbol}", - "withdrawSymbol": "Withdraw %{symbol}", + "enterSymbol": "Enter %{symbol}", + "exitSymbol": "Exit %{symbol}", "claimSymbol": "Claim %{symbol}", "noActivePositions": "You do not have any active yield positions.", "connectWalletPositions": "Connect a wallet to view your active yield positions.", @@ -2770,7 +2745,7 @@ "assetYields": "%{asset} Yields", "opportunitiesAvailable": "%{count} opportunities available", "noYieldsMatchingFilters": "No yields found matching filters.", - "activeDeposits": "Active Deposits", + "activePositions": "Active Positions", "acrossPositions": "Across %{count} positions", "availableToEarn": "Available to Earn", "idleAssetsEarning": "Idle assets that could be earning up to %{apy}% APY", @@ -2779,22 +2754,20 @@ "earn": "Earn", "myBalance": "My Balance", "providers": "Providers", - "deposit": "Deposit", - "withdraw": "Withdraw", - "successDeposit": "You successfully deposited %{amount} %{symbol}", - "successWithdraw": "You successfully withdrew %{amount} %{symbol}", + "successEnter": "You successfully entered %{amount} %{symbol}", + "successExit": "You successfully exited %{amount} %{symbol}", "successClaim": "You successfully claimed %{amount} %{symbol}", + "resetAllowance": "Reset Allowance", + "transactionNumber": "Transaction %{number}", "loading": { "signInWallet": "Sign in Wallet", - "signNow": "Sign now...", "waiting": "Waiting", "done": "Done", - "preparing": "Preparing...", + "failed": "Failed", "preparingTransaction": "Preparing transaction..." }, "errors": { "walletNotConnected": "Wallet not connected", - "unsupportedYieldNetwork": "Unsupported yield network", "broadcastFailed": "Failed to broadcast transaction", "transactionFailedTitle": "Transaction failed", "transactionFailedDescription": "Please try again.", diff --git a/src/components/ReactTable/ReactTable.tsx b/src/components/ReactTable/ReactTable.tsx index 77bfc3ac418..9a2bcd65868 100644 --- a/src/components/ReactTable/ReactTable.tsx +++ b/src/components/ReactTable/ReactTable.tsx @@ -49,10 +49,14 @@ const arrowBackIcon = const arrowForwardIcon = const CellWrap = ({ cell }: { cell: Cell }) => { - const cellProps = useMemo(() => cell.getCellProps(), [cell]) - const dataLabel = useMemo(() => { - return typeof cell.column.Header === 'string' ? cell.column.Header : undefined - }, [cell.column.Header]) + const cellProps = useMemo(() => { + const { key: _key, ...rest } = cell.getCellProps() + return rest + }, [cell]) + const dataLabel = useMemo( + () => (typeof cell.column.Header === 'string' ? cell.column.Header : undefined), + [cell.column.Header], + ) return ( ({ cell }: { cell: Cell }) => { data-label={dataLabel} display={cell.column.display} textAlign={cell.column.textAlign} - key={cell.column.id} > {cell.render('Cell')} @@ -94,7 +97,10 @@ const RowWrap = ({ onRowLongPress?.(row as Row) }, defaultLongPressConfig) - const rowProps = useMemo(() => row.getRowProps(), [row]) + const rowProps = useMemo(() => { + const { key: _key, ...rest } = row.getRowProps() + return rest + }, [row]) const dataTest = useMemo(() => { if (!rowDataTestKey) return undefined @@ -229,33 +235,48 @@ export const ReactTable = ({ {displayHeaders && ( - {headerGroups.map(headerGroup => ( - - {headerGroup.headers.map(column => ( - - ))} - - ))} + {headerGroups.map(headerGroup => { + const { key: headerGroupKey, ...headerGroupProps } = headerGroup.getHeaderGroupProps() + return ( + + {headerGroup.headers.map(column => { + const { key: columnKey, ...columnProps } = column.getHeaderProps( + column.getSortByToggleProps(), + ) + return ( + + ) + })} + + ) + })} )} {renderedRows} diff --git a/src/config.ts b/src/config.ts index 6e55bf894bf..d24f2055e2d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -235,6 +235,7 @@ const validators = { VITE_FEATURE_ADDRESS_BOOK: bool({ default: false }), VITE_FEATURE_APP_RATING: bool({ default: false }), VITE_FEATURE_YIELD_XYZ: bool({ default: false }), + VITE_FEATURE_YIELDS_PAGE: 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/constants.ts b/src/lib/yieldxyz/constants.ts index 44b4773b531..55a937d04ba 100644 --- a/src/lib/yieldxyz/constants.ts +++ b/src/lib/yieldxyz/constants.ts @@ -20,6 +20,7 @@ import { } from '@shapeshiftoss/caip' import invert from 'lodash/invert' +import type { ValidatorDto } from './types' import { YieldNetwork } from './types' export const CHAIN_ID_TO_YIELD_NETWORK: Partial> = { @@ -62,6 +63,27 @@ export const SHAPESHIFT_VALIDATOR_LOGO = export const COSMOS_SHAPESHIFT_FALLBACK_APR = '0.1425' +export const COSMOS_NETWORK_FALLBACK_APR = 0.15 + +export const SHAPESHIFT_VALIDATOR_NAME = 'ShapeShift DAO' + +export const SHAPESHIFT_VALIDATOR: ValidatorDto = { + address: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + preferred: true, + votingPower: 0, + commission: 0.1, + status: 'active', + tvl: '0', + tvlRaw: '0', + rewardRate: { + total: parseFloat(COSMOS_SHAPESHIFT_FALLBACK_APR), + rateType: 'APR', + components: [], + }, +} + export const COSMOS_DECIMALS = 6 export const COSMOS_ATOM_NATIVE_STAKING_YIELD_ID = 'cosmos-atom-native-staking' diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index f71a9fb4993..e0feda81908 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -1,6 +1,7 @@ import type { ChainId } from '@shapeshiftoss/caip' import { + COSMOS_NETWORK_FALLBACK_APR, isSupportedYieldNetwork, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, YIELD_NETWORK_TO_CHAIN_ID, @@ -16,29 +17,29 @@ export const yieldNetworkToChainId = (network: string): ChainId | undefined => { const TX_TITLE_PATTERNS: [RegExp, string][] = [ [/approv/i, 'Approve'], - [/supply|deposit|enter/i, 'Deposit'], - [/withdraw|exit/i, 'Withdraw'], + [/supply|deposit|enter/i, 'Enter'], + [/withdraw|exit|unstake|undelegate/i, 'Exit'], [/claim/i, 'Claim'], - [/unstake|undelegate/i, 'Unstake'], - [/stake|delegate/i, 'Stake'], + [/stake|delegate/i, 'Enter'], [/bridge/i, 'Bridge'], [/swap/i, 'Swap'], ] // Map of transaction types to user-friendly button labels // These should match the action verbs shown in the step row (without the asset symbol) +// Yield.xyz uses Enter/Exit terminology consistently const TX_TYPE_TO_LABEL: Record = { APPROVE: 'Approve', APPROVAL: 'Approve', - DELEGATE: 'Stake', // Monad uses DELEGATE for staking - UNDELEGATE: 'Unstake', // Monad uses UNDELEGATE for unstaking - STAKE: 'Stake', - UNSTAKE: 'Unstake', - DEPOSIT: 'Deposit', - WITHDRAW: 'Withdraw', - SUPPLY: 'Deposit', - EXIT: 'Withdraw', - ENTER: 'Deposit', + DELEGATE: 'Enter', // Monad uses DELEGATE for staking + UNDELEGATE: 'Exit', // Monad uses UNDELEGATE for unstaking + STAKE: 'Enter', + UNSTAKE: 'Exit', + DEPOSIT: 'Enter', + WITHDRAW: 'Exit', + SUPPLY: 'Enter', + EXIT: 'Exit', + ENTER: 'Enter', BRIDGE: 'Bridge', SWAP: 'Swap', CLAIM: 'Claim', @@ -147,3 +148,15 @@ export const sortValidators = ( export const toUserCurrency = (usdAmount: string | number, rate: string | number): string => bnOrZero(usdAmount).times(rate).toFixed() + +export const ensureValidatorApr = (validator: ValidatorDto): ValidatorDto => + validator.rewardRate?.total + ? validator + : { + ...validator, + rewardRate: { + total: COSMOS_NETWORK_FALLBACK_APR, + rateType: 'APR' as const, + components: validator.rewardRate?.components ?? [], + }, + } diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index 0aeea1a69ac..28776aaf989 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -412,7 +412,7 @@ export const YieldAssetDetails = memo(() => { ]) return ( - + {assetHeaderElement} - - - - - + + {contentElement} diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 3587d39746f..0c9fd5d3386 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -8,7 +8,6 @@ import { Heading, HStack, Text, - useColorModeValue, } from '@chakra-ui/react' import { memo, useEffect, useMemo } from 'react' import { FaChevronLeft } from 'react-icons/fa' @@ -50,13 +49,6 @@ export const YieldDetail = memo(() => { [yieldItem?.providerId, yieldProviders], ) - const bgColor = useColorModeValue('gray.50', 'gray.900') - const borderColor = useColorModeValue('gray.200', 'gray.800') - const heroBg = useColorModeValue('gray.100', 'gray.900') - const heroTextColor = useColorModeValue('gray.900', 'white') - const heroSubtleColor = useColorModeValue('gray.600', 'gray.400') - const heroIconBorderColor = useColorModeValue('gray.200', 'gray.800') - const { data: allBalancesData, isFetching: isBalancesFetching } = useAllYieldBalances() const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined const isBalancesLoading = !allBalancesData && isBalancesFetching @@ -82,7 +74,7 @@ export const YieldDetail = memo(() => { const errorElement = useMemo( () => ( - + {translate('common.error')} @@ -95,39 +87,24 @@ export const YieldDetail = memo(() => { ), - [error, heroBg, navigate, translate], + [error, navigate, translate], ) - const iconBg = useColorModeValue('white', 'gray.800') - const heroIcon = useMemo(() => { if (!yieldItem) return null const iconSource = resolveYieldInputAssetIcon(yieldItem) - if (iconSource.assetId) - return ( - - ) - return ( - - ) - }, [heroIconBorderColor, yieldItem, iconBg]) + const iconProps = { + showNetworkIcon: false, + boxSize: { base: 16, md: 24 }, + boxShadow: '2xl', + border: '4px solid', + borderColor: 'border.base', + borderRadius: 'full', + bg: 'background.surface.base', + } + if (iconSource.assetId) return + return + }, [yieldItem]) const providerOrValidatorsElement = useMemo(() => { if (!yieldItem) return null @@ -140,7 +117,7 @@ export const YieldDetail = memo(() => { ))} - + {validators.length > 3 ? `${validators.length} Validators` : 'Validators'} @@ -150,68 +127,81 @@ export const YieldDetail = memo(() => { - + {yieldItem.providerId} ) - }, [ - heroTextColor, - providerLogo, - shouldFetchValidators, - uniqueValidatorCount, - validators, - yieldItem, - ]) + }, [providerLogo, shouldFetchValidators, uniqueValidatorCount, validators, yieldItem]) const chainElement = useMemo(() => { if (!yieldItem?.chainId) return null return ( - + {yieldItem.network} ) - }, [heroTextColor, yieldItem?.chainId, yieldItem?.network]) + }, [yieldItem?.chainId, yieldItem?.network]) if (isLoading) return loadingElement if (error || !yieldItem) return errorElement return ( - - - + + + - + {heroIcon} - - + + {yieldItem.metadata.name} - + {providerOrValidatorsElement} - {chainElement} - + {chainElement} + {yieldItem.metadata.description} - + { + if (status === 'success') { + return + } + if (status === 'failed') { + return + } + if (status === 'loading') { + return + } + return +}) + +const getStatusText = ( + step: TransactionStep, + translate: ReturnType, +): string | null => { + if (step.txHash) return null + if (step.loadingMessage) return step.loadingMessage + if (step.status === 'pending') return translate('yieldXYZ.loading.waiting') + if (step.status === 'success') return translate('yieldXYZ.loading.done') + if (step.status === 'failed') return translate('yieldXYZ.loading.failed') + return null +} + +export const TransactionStepsList = memo(({ steps }: TransactionStepsListProps) => { + const translate = useTranslate() + + const stepElements = useMemo(() => { + return steps.map((step, idx) => { + const statusText = getStatusText(step, translate) + return ( + + + + + {step.title} + + + {step.txHash && step.txUrl ? ( + + + + + ) : statusText ? ( + + {statusText} + + ) : null} + + ) + }) + }, [steps, translate]) + + if (!steps.length) return null + + return ( + + {stepElements} + + ) +}) diff --git a/src/pages/Yields/components/ValidatorBreakdown.tsx b/src/pages/Yields/components/ValidatorBreakdown.tsx index dd6b0efd6ba..c6f107d82a2 100644 --- a/src/pages/Yields/components/ValidatorBreakdown.tsx +++ b/src/pages/Yields/components/ValidatorBreakdown.tsx @@ -11,7 +11,6 @@ import { HStack, Skeleton, Text, - useColorModeValue, useDisclosure, VStack, } from '@chakra-ui/react' @@ -62,7 +61,6 @@ type ValidatorCardProps = { validatorSummary: ValidatorSummary isSelected: boolean userCurrencyToUsdRate: string - hoverBg: string onValidatorSwitch: (e: React.MouseEvent) => void onClaimClick: (e: React.MouseEvent) => void formatUnlockDate: (dateString: string | undefined) => string | null @@ -165,27 +163,8 @@ const BalanceRow: FC = memo( ) const ValidatorCard: FC = memo( - ({ - validatorSummary, - isSelected, - userCurrencyToUsdRate, - hoverBg, - onValidatorSwitch, - onClaimClick, - }) => { + ({ validatorSummary, isSelected, userCurrencyToUsdRate, onValidatorSwitch, onClaimClick }) => { const translate = useTranslate() - const enteringBg = useColorModeValue('blue.50', 'blue.900') - const enteringTextColor = useColorModeValue('blue.700', 'blue.300') - const enteringDateColor = useColorModeValue('blue.600', 'blue.400') - const enteringValueColor = useColorModeValue('blue.800', 'blue.200') - const exitingBg = useColorModeValue('orange.50', 'orange.900') - const exitingTextColor = useColorModeValue('orange.700', 'orange.300') - const exitingDateColor = useColorModeValue('orange.600', 'orange.400') - const exitingValueColor = useColorModeValue('orange.800', 'orange.200') - const claimableBg = useColorModeValue('purple.50', 'purple.900') - const claimableTextColor = useColorModeValue('purple.700', 'purple.300') - const claimableValueColor = useColorModeValue('purple.800', 'purple.200') - const { validator, byType, @@ -213,7 +192,14 @@ const ValidatorCard: FC = memo( ) return ( - + {!isSelected && ( )} - + @@ -246,34 +232,34 @@ const ValidatorCard: FC = memo( - + @@ -289,10 +275,6 @@ export const ValidatorBreakdown = memo( const [claimModalData, setClaimModalData] = useState(null) const handleClaimClose = useCallback(() => setClaimModalData(null), []) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const { chainId } = yieldItem const { accountNumber } = useYieldAccount() const accountId = useAppSelector(state => { @@ -371,13 +353,7 @@ export const ValidatorBreakdown = memo( const loadingElement = useMemo( () => ( - + @@ -387,7 +363,7 @@ export const ValidatorBreakdown = memo( ), - [borderColor, cardBg], + [], ) const claimModalElement = useMemo(() => { @@ -415,7 +391,7 @@ export const ValidatorBreakdown = memo( if (!hasValidatorPositions) return null return ( - + - {index > 0 && } + {index > 0 && } +const walletIcon = const checkIconBox = ( - + ) @@ -77,12 +65,6 @@ export const YieldActionModal = memo(function YieldActionModal({ ...props }: YieldActionModalProps) { const translate = useTranslate() - const modalBg = useColorModeValue('white', 'gray.900') - const modalBorderColor = useColorModeValue('gray.200', 'gray.700') - const cardBg = useColorModeValue('gray.50', 'gray.800') - const cardBorderColor = useColorModeValue('gray.200', 'whiteAlpha.100') - const subtleTextColor = useColorModeValue('gray.600', 'gray.400') - const avatarBg = useColorModeValue('gray.100', 'gray.900') const { step, @@ -94,6 +76,8 @@ export const YieldActionModal = memo(function YieldActionModal({ handleClose, isQuoteLoading, quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, } = useYieldTransactionFlow({ yieldItem, action, @@ -195,18 +179,17 @@ export const YieldActionModal = memo(function YieldActionModal({ ) const isButtonLoading = useMemo( - () => isSubmitting || isQuoteLoading, - [isSubmitting, isQuoteLoading], + () => isSubmitting || isQuoteLoading || isAllowanceCheckPending, + [isSubmitting, isQuoteLoading, isAllowanceCheckPending], ) const loadingText = useMemo(() => { if (isQuoteLoading) return translate('yieldXYZ.loadingQuote') - // Use the current step's loading message if available if (activeStepIndex >= 0 && transactionSteps[activeStepIndex]?.loadingMessage) { return transactionSteps[activeStepIndex].loadingMessage } - if (action === 'enter') return translate('yieldXYZ.depositing') - if (action === 'exit') return translate('yieldXYZ.withdrawing') + if (action === 'enter') return translate('yieldXYZ.entering') + if (action === 'exit') return translate('yieldXYZ.exiting') return translate('common.claiming') }, [isQuoteLoading, action, translate, activeStepIndex, transactionSteps]) @@ -222,22 +205,21 @@ export const YieldActionModal = memo(function YieldActionModal({ return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) } // Fallback to action-based text - if (action === 'enter') return translate('yieldXYZ.deposit') - if (action === 'exit') return translate('yieldXYZ.withdraw') + if (action === 'enter') return translate('yieldXYZ.enter') + if (action === 'exit') return translate('yieldXYZ.exit') return translate('common.claim') }, [action, translate, activeStepIndex, transactionSteps, quoteData]) const modalHeading = useMemo(() => { - if (action === 'enter') return translate('yieldXYZ.supplySymbol', { symbol: assetSymbol }) - if (action === 'exit') return translate('yieldXYZ.withdrawSymbol', { symbol: assetSymbol }) + if (action === 'enter') return translate('yieldXYZ.enterSymbol', { symbol: assetSymbol }) + if (action === 'exit') return translate('yieldXYZ.exitSymbol', { symbol: assetSymbol }) return translate('yieldXYZ.claimSymbol', { symbol: assetSymbol }) }, [action, assetSymbol, translate]) const successMessage = useMemo(() => { if (action === 'enter') - return translate('yieldXYZ.successDeposit', { symbol: assetSymbol, amount }) - if (action === 'exit') - return translate('yieldXYZ.successWithdraw', { symbol: assetSymbol, amount }) + 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]) @@ -252,296 +234,184 @@ export const YieldActionModal = memo(function YieldActionModal({ 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) { - return quoteData.transactions - .filter(tx => tx.status === 'CREATED') - .map((tx, i) => ({ - title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), - originalTitle: tx.title || '', - type: tx.type, + 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]) + }, [ + transactionSteps, + quoteData, + assetSymbol, + isAllowanceCheckPending, + isUsdtResetRequired, + translate, + ]) - const statusCard = useMemo( + const animatedAvatarRow = useMemo( () => ( - - - - - - - - - - - - {assetSymbol} - - + + + + + + + {assetSymbol} + + + + - - + + + + - - - - - - {vaultMetadata.name} - - + + {vaultMetadata.name} + + + + ), + [ + flexDirection, + assetAvatarSrc, + assetSymbol, + horizontalScroll, + vaultMetadata.logoURI, + vaultMetadata.name, + ], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('common.amount')} + + - - {action === 'enter' && ( - <> - - - {translate('yieldXYZ.apr')} + {action === 'enter' && ( + <> + + + {translate('yieldXYZ.apr')} + + + {aprFormatted} + + + {showEstimatedEarnings && ( + + + {translate('yieldXYZ.estEarnings')} - - {aprFormatted} - - - {showEstimatedEarnings && ( - - - {translate('yieldXYZ.estEarnings')} + + + {estimatedEarningsAmount} + + + - - - - {estimatedEarningsAmount} - - - - - - - )} - - )} - {showValidatorRow && ( - - - {translate('yieldXYZ.validator')} - - - - - {vaultMetadata.name} - - - )} - {!isStaking && ( - - - {translate('yieldXYZ.provider')} + )} + + )} + {showValidatorRow && ( + + + {translate('yieldXYZ.validator')} + + + + + {vaultMetadata.name} - - - - {vaultMetadata.name} - - - )} - - - {translate('yieldXYZ.network')} + + )} + {!isStaking && ( + + + {translate('yieldXYZ.provider')} - {feeAsset && } - - {yieldItem.network} + + + {vaultMetadata.name} - - - {displaySteps.map((s, idx) => ( - - - {s.status === 'success' ? ( - - ) : s.status === 'loading' ? ( - - ) : ( - - )} - - {s.title} - - - {s.txHash ? ( - - - - ) : ( - - {s.status === 'success' - ? translate('yieldXYZ.loading.done') - : s.status === 'loading' - ? '' - : translate('yieldXYZ.loading.waiting')} - - )} - - ))} - + )} + + + {translate('yieldXYZ.network')} + + + {feeAsset && } + + {yieldItem.network} + + + ), [ - cardBg, - cardBorderColor, amount, assetSymbol, - flexDirection, - avatarBg, - assetAvatarSrc, - subtleTextColor, - horizontalScroll, - vaultMetadata.logoURI, - vaultMetadata.name, action, translate, aprFormatted, @@ -550,77 +420,26 @@ export const YieldActionModal = memo(function YieldActionModal({ estimatedEarningsFiat, showValidatorRow, isStaking, + vaultMetadata.logoURI, + vaultMetadata.name, feeAsset, networkAvatarSrc, yieldItem.network, - displaySteps, ], ) const actionContent = useMemo( () => ( - - {statusCard} - - + + {animatedAvatarRow} + {statsContent} + + ), - [statusCard, handleConfirm, isButtonDisabled, isButtonLoading, loadingText, buttonText], + [animatedAvatarRow, statsContent, displaySteps], ) - const refAnimationInstance = useRef(null) - const getInstance = useCallback(({ confetti }: { confetti: TCanvasConfettiInstance }) => { - refAnimationInstance.current = confetti - }, []) - - const makeShot = useCallback((particleRatio: number, opts: Partial) => { - if (refAnimationInstance.current) { - refAnimationInstance.current({ - ...opts, - origin: { y: 0.7 }, - particleCount: Math.floor(200 * particleRatio), - }) - } - }, []) - - const fireConfetti = useCallback(() => { - makeShot(0.25, { - spread: 26, - startVelocity: 55, - }) - makeShot(0.2, { - spread: 60, - }) - makeShot(0.35, { - spread: 100, - decay: 0.91, - scalar: 0.8, - }) - makeShot(0.1, { - spread: 120, - startVelocity: 25, - decay: 0.92, - scalar: 1.2, - }) - makeShot(0.1, { - spread: 120, - startVelocity: 45, - }) - }, [makeShot]) + const { getInstance, fireConfetti, confettiStyle } = useConfetti() useEffect(() => { if (step === ModalStep.Success) fireConfetti() @@ -628,139 +447,106 @@ export const YieldActionModal = memo(function YieldActionModal({ const successContent = useMemo( () => ( - + - - - - - {translate('yieldXYZ.success')} - - - {successMessage} - + - - - - {translate('yieldXYZ.transactions')} + + {successMessage} + + {vaultMetadata && ( + + + + {vaultMetadata.name} - {transactionSteps.map((s, idx) => ( - - - - - {s.title} - - - {s.txHash && ( - - {translate('yieldXYZ.view')} - - )} - - ))} - + + )} + + - ), - [translate, successMessage, transactionSteps, handleClose], - ) - - const confettiStyle = useMemo( - () => ({ - position: 'fixed' as const, - pointerEvents: 'none' as const, - width: '100%', - height: '100%', - top: 0, - left: 0, - zIndex: 9999, - }), - [], + [successMessage, vaultMetadata, transactionSteps], ) - const isNotSuccess = useMemo(() => step !== ModalStep.Success, [step]) - const isInProgress = useMemo(() => step === ModalStep.InProgress, [step]) - const isSuccess = useMemo(() => step === ModalStep.Success, [step]) - - const headerContent = useMemo(() => { - if (!isNotSuccess) return null - return ( - - - {modalHeading} - - - ) - }, [isNotSuccess, modalHeading]) + const isInProgress = step === ModalStep.InProgress + const isSuccess = step === ModalStep.Success return ( <> - + - - - - - {headerContent} - {isInProgress && actionContent} - {isSuccess && successContent} - - - - + + {null} + + {isSuccess ? translate('common.success') : modalHeading} + + + + + + + {isInProgress && actionContent} + {isSuccess && successContent} + + {isInProgress && ( + + + + )} + {isSuccess && ( + + + + )} + ) }) diff --git a/src/pages/Yields/components/YieldActivePositions.tsx b/src/pages/Yields/components/YieldActivePositions.tsx index f4859f227c4..eeb29dac83f 100644 --- a/src/pages/Yields/components/YieldActivePositions.tsx +++ b/src/pages/Yields/components/YieldActivePositions.tsx @@ -10,7 +10,6 @@ import { Th, Thead, Tr, - useColorModeValue, } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { memo, useCallback, useMemo } from 'react' @@ -18,10 +17,9 @@ import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' -import { AssetIcon } from '@/components/AssetIcon' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { resolveYieldInputAssetIcon, toUserCurrency } from '@/lib/yieldxyz/utils' +import { toUserCurrency } from '@/lib/yieldxyz/utils' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { selectAssetById, selectUserCurrencyToUsdRate } from '@/state/slices/selectors' @@ -39,8 +37,6 @@ export const YieldActivePositions = memo( const navigate = useNavigate() const asset = useAppSelector(state => selectAssetById(state, assetId)) const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const borderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const { data: providers } = useYieldProviders() @@ -69,8 +65,6 @@ export const YieldActivePositions = memo( [activeYields, aggregated], ) - const assetColumnHeader = useMemo(() => translate('yieldXYZ.asset') ?? 'Asset', [translate]) - const providerColumnHeader = useMemo( () => hasValidators @@ -88,14 +82,6 @@ export const YieldActivePositions = memo( [translate], ) - const yourBalanceLabel = useMemo(() => translate('defi.yourBalance'), [translate]) - - const renderAssetIcon = useCallback((yieldItem: AugmentedYieldDto) => { - const iconSource = resolveYieldInputAssetIcon(yieldItem) - if (iconSource.assetId) return - return - }, []) - const tableRows = useMemo(() => { if (!asset) return null @@ -114,17 +100,9 @@ export const YieldActivePositions = memo( return ( handleRowClick(yieldItem.id, validator.address)} > - handleRowClick(yieldItem.id)} > - ) }), - [rows, handleRowClick, hoverBg], + [rows, handleRowClick], ) const tbodyContent = useMemo( @@ -108,7 +95,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp textAlign={meta?.textAlign} cursor={canSort ? 'pointer' : undefined} onClick={canSort ? sortingHandler : undefined} - _hover={canSort ? { color: hoverColor } : undefined} + _hover={canSort ? { color: 'text.base' } : undefined} > +const searchIcon = type YieldValidatorSelectModalProps = { isOpen: boolean @@ -50,9 +49,6 @@ export const YieldValidatorSelectModal = memo( const translate = useTranslate() const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) const [searchQuery, setSearchQuery] = useState('') - const bgColor = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') const balanceMap = useMemo(() => { if (!balances) return new Map() @@ -83,24 +79,28 @@ export const YieldValidatorSelectModal = memo( continue seen.add(balance.validator.address) const full = validators.find(v => v.address === balance.validator?.address) - result.push( - full ?? { - address: balance.validator.address, - name: balance.validator.name, - logoURI: balance.validator.logoURI, - preferred: false, - votingPower: 0, - commission: balance.validator.commission ?? 0, - status: balance.validator.status ?? 'active', - tvl: '0', - tvlRaw: '0', - rewardRate: { - total: balance.validator.apr ?? 0, - rateType: 'APR' as const, - components: [], - }, - }, - ) + if (full) { + result.push(full) + } else { + result.push( + ensureValidatorApr({ + address: balance.validator.address, + name: balance.validator.name, + logoURI: balance.validator.logoURI, + preferred: false, + votingPower: 0, + commission: balance.validator.commission ?? 0, + status: balance.validator.status ?? 'active', + tvl: '0', + tvlRaw: '0', + rewardRate: { + total: balance.validator.apr ?? 0, + rateType: 'APR' as const, + components: [], + }, + }), + ) + } } return result }, [balances, validators]) @@ -108,7 +108,7 @@ export const YieldValidatorSelectModal = memo( const allValidators = validators const filteredAll = useMemo( - () => sortValidators(searchValidators(allValidators, searchQuery)), + () => searchValidators(allValidators, searchQuery), [allValidators, searchQuery], ) @@ -144,7 +144,7 @@ export const YieldValidatorSelectModal = memo( p={4} cursor='pointer' borderRadius='lg' - _hover={{ bg: hoverBg }} + _hover={{ bg: 'background.surface.raised.base' }} onClick={() => handleSelect(v.address)} > @@ -185,13 +185,13 @@ export const YieldValidatorSelectModal = memo( ) }, - [balanceMap, userCurrencyToUsdRate, hoverBg, handleSelect, translate], + [balanceMap, userCurrencyToUsdRate, handleSelect, translate], ) return ( - + {translate('yieldXYZ.selectValidator')} @@ -214,7 +214,7 @@ export const YieldValidatorSelectModal = memo( {filteredAll.length === 0 ? ( - + {translate('yieldXYZ.noValidatorsFound')} ) : ( @@ -225,7 +225,7 @@ export const YieldValidatorSelectModal = memo( {filteredMy.length === 0 ? ( - + {translate('yieldXYZ.noActiveValidators')} ) : ( diff --git a/src/pages/Yields/components/YieldViewHelpers.tsx b/src/pages/Yields/components/YieldViewHelpers.tsx index d8a068e1060..98fbad04f78 100644 --- a/src/pages/Yields/components/YieldViewHelpers.tsx +++ b/src/pages/Yields/components/YieldViewHelpers.tsx @@ -19,22 +19,22 @@ export const ViewToggle = memo(({ viewMode, setViewMode }: ViewToggleProps) => { const handleSetListView = useCallback(() => setViewMode('list'), [setViewMode]) return ( - - - - - - + + + + ) }) diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 62d90cfb6eb..6e8ba7c5174 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -18,7 +18,6 @@ import { TabPanels, Tabs, Text, - useColorModeValue, } from '@chakra-ui/react' import type { ColumnDef, Row } from '@tanstack/react-table' import { getCoreRowModel, getSortedRowModel, useReactTable } from '@tanstack/react-table' @@ -50,13 +49,13 @@ import { } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const tabSelectedSx = { color: 'white', bg: 'blue.500' } + export const YieldsList = memo(() => { const translate = useTranslate() const navigate = useNavigate() const { state: walletState } = useWallet() const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - const headerBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const searchInputBg = useColorModeValue('white', 'gray.800') const [searchParams, setSearchParams] = useSearchParams() const tabParam = useMemo(() => searchParams.get('tab'), [searchParams]) const tabIndex = useMemo(() => (tabParam === 'my-positions' ? 1 : 0), [tabParam]) @@ -519,7 +518,7 @@ export const YieldsList = memo(() => { gap={4} borderBottomWidth='1px' borderColor='inherit' - bg={headerBg} + bg='background.surface.raised.base' > @@ -567,7 +566,7 @@ export const YieldsList = memo(() => { ))} ), - [filterSearchString, headerBg, translate, yieldsByAsset], + [filterSearchString, translate, yieldsByAsset], ) const allYieldsContentElement = useMemo(() => { @@ -599,7 +598,7 @@ export const YieldsList = memo(() => { const positionsEmptyElement = useMemo( () => ( - + {translate('yieldXYZ.noYields')} @@ -678,7 +677,7 @@ export const YieldsList = memo(() => { ]) return ( - + {translate('yieldXYZ.pageTitle')} @@ -686,13 +685,16 @@ export const YieldsList = memo(() => { {translate('yieldXYZ.pageSubtitle')} {errorElement} - + {isConnected && ( + + )} { onChange={handleTabChange} > - {translate('common.all')} - - {translate('yieldXYZ.myPosition')} ({myPositions.length}) + {translate('common.all')} + + {translate('yieldXYZ.myPositions')} ({myPositions.length}) { > - + - { mb={0} /> - + {allYieldsContentElement} diff --git a/src/pages/Yields/hooks/useConfetti.ts b/src/pages/Yields/hooks/useConfetti.ts new file mode 100644 index 00000000000..d19f9a3721f --- /dev/null +++ b/src/pages/Yields/hooks/useConfetti.ts @@ -0,0 +1,44 @@ +import type { Options } from 'canvas-confetti' +import { useCallback, useMemo, useRef } from 'react' +import type { TCanvasConfettiInstance } from 'react-canvas-confetti/dist/types' + +export const useConfetti = () => { + const refAnimationInstance = useRef(null) + + const getInstance = useCallback(({ confetti }: { confetti: TCanvasConfettiInstance }) => { + refAnimationInstance.current = confetti + }, []) + + const makeShot = useCallback((particleRatio: number, opts: Partial) => { + if (refAnimationInstance.current) { + refAnimationInstance.current({ + ...opts, + origin: { y: 0.7 }, + particleCount: Math.floor(200 * particleRatio), + }) + } + }, []) + + const fireConfetti = useCallback(() => { + makeShot(0.25, { spread: 26, startVelocity: 55 }) + makeShot(0.2, { spread: 60 }) + makeShot(0.35, { spread: 100, decay: 0.91, scalar: 0.8 }) + makeShot(0.1, { spread: 120, startVelocity: 25, decay: 0.92, scalar: 1.2 }) + makeShot(0.1, { spread: 120, startVelocity: 45 }) + }, [makeShot]) + + const confettiStyle = useMemo( + () => ({ + position: 'fixed' as const, + pointerEvents: 'none' as const, + width: '100%', + height: '100%', + top: 0, + left: 0, + zIndex: 9999, + }), + [], + ) + + return { getInstance, fireConfetti, confettiStyle } +} diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 200350d7a6a..c4c3973948a 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -1,13 +1,16 @@ import { useToast } from '@chakra-ui/react' -import type { AssetId } from '@shapeshiftoss/caip' -import { cosmosChainId, fromAccountId } from '@shapeshiftoss/caip' +import type { AssetId, ChainId } from '@shapeshiftoss/caip' +import { cosmosChainId, ethChainId, fromAccountId, usdtAssetId } 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 { useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' +import type { Hash } from 'viem' import { SECOND_CLASS_CHAINS } from '@/constants/chains' +import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' @@ -22,6 +25,8 @@ import type { ActionDto, AugmentedYieldDto, TransactionDto } from '@/lib/yieldxy import { ActionStatus as YieldActionStatus, TransactionStatus } from '@/lib/yieldxyz/types' import { formatYieldTxTitle } from '@/lib/yieldxyz/utils' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' +import { reactQueries } from '@/react-queries' +import { useAllowance } from '@/react-queries/hooks/useAllowance' import { useSubmitYieldTransactionHash } from '@/react-queries/queries/yieldxyz/useSubmitYieldTransactionHash' import { actionSlice } from '@/state/slices/actionSlice/actionSlice' import { @@ -44,7 +49,7 @@ export enum ModalStep { export type TransactionStep = { title: string - status: 'pending' | 'success' | 'loading' + status: 'pending' | 'success' | 'loading' | 'failed' originalTitle: string type?: string txHash?: string @@ -67,8 +72,8 @@ const poll = async ( throw new Error('Polling timed out') } -const waitForActionCompletion = (actionId: string): Promise => { - return poll( +export const waitForActionCompletion = (actionId: string): Promise => + poll( () => fetchAction(actionId), action => action.status === YieldActionStatus.Success, action => { @@ -77,9 +82,27 @@ const waitForActionCompletion = (actionId: string): Promise => { return undefined }, ) -} -const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { +export const waitForTransactionConfirmation = ( + actionId: string, + transactionId: string, +): Promise => + poll( + () => fetchAction(actionId), + action => { + const tx = action.transactions.find(t => t.id === transactionId) + return tx?.status !== TransactionStatus.Created + }, + action => { + if (action.status === YieldActionStatus.Failed) return new Error('Action failed') + if (action.status === YieldActionStatus.Canceled) return new Error('Action was canceled') + const tx = action.transactions.find(t => t.id === transactionId) + if (tx?.status === TransactionStatus.Failed) return new Error('Transaction failed') + return undefined + }, + ) + +export const filterExecutableTransactions = (transactions: TransactionDto[]): TransactionDto[] => { const seen = new Set() return transactions.filter(tx => { if (tx.status !== TransactionStatus.Created) return false @@ -89,6 +112,29 @@ const filterExecutableTransactions = (transactions: TransactionDto[]): Transacti }) } +export const getSpenderFromApprovalTx = (tx: TransactionDto): string | null => { + try { + const parsed = JSON.parse(tx.unsignedTransaction) + const data = parsed.data as string | undefined + if (!data || !data.toLowerCase().startsWith('0x095ea7b3')) return null + return ('0x' + data.slice(10, 74).slice(-40)).toLowerCase() + } catch { + return null + } +} + +export const isApprovalTransaction = (tx: TransactionDto): boolean => { + const type = tx.type?.toUpperCase() + return type === 'APPROVE' || type === 'APPROVAL' +} + +export const isUsdtOnEthereumMainnet = ( + assetId: string | undefined, + chainId: ChainId | undefined, +): boolean => { + return assetId === usdtAssetId && chainId === ethChainId +} + type UseYieldTransactionFlowProps = { yieldItem: AugmentedYieldDto action: 'enter' | 'exit' | 'manage' @@ -126,9 +172,16 @@ export const useYieldTransactionFlow = ({ const [isSubmitting, setIsSubmitting] = useState(false) const [activeStepIndex, setActiveStepIndex] = useState(-1) const [currentActionId, setCurrentActionId] = useState(null) + const [resetTxHash, setResetTxHash] = useState(null) + const isUsdtApprovalResetEnabled = useFeatureFlag('UsdtApprovalReset') const submitHashMutation = useSubmitYieldTransactionHash() + const inputTokenAssetId = useMemo( + () => yieldItem.inputTokens[0]?.assetId, + [yieldItem.inputTokens], + ) + const { chainId: yieldChainId } = yieldItem const { accountNumber } = useYieldAccount() @@ -220,6 +273,60 @@ export const useYieldTransactionFlow = ({ retry: false, }) + // USDT reset logic - only for enter action on USDT/ETH + const approvalSpender = useMemo(() => { + if (action !== 'enter') return null + 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) + }, [action, quoteData?.transactions]) + + const allowanceQuery = useAllowance({ + assetId: inputTokenAssetId, + spender: approvalSpender ?? undefined, + from: userAddress || undefined, + isDisabled: !approvalSpender || !isUsdtApprovalResetEnabled || action !== 'enter', + isRefetchEnabled: true, + }) + + const isUsdtResetRequired = useMemo(() => { + if (action !== 'enter') return false + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId)) return false + if (!approvalSpender) return false + if (!allowanceQuery.data) return false + return bnOrZero(allowanceQuery.data).gt(0) + }, [ + action, + isUsdtApprovalResetEnabled, + inputTokenAssetId, + yieldChainId, + approvalSpender, + allowanceQuery.data, + ]) + + // Check if we're waiting for USDT allowance check before we can determine reset requirement + const isAllowanceCheckPending = useMemo(() => { + if (action !== 'enter') return false + if (!isUsdtApprovalResetEnabled) return false + if (!isUsdtOnEthereumMainnet(inputTokenAssetId, yieldChainId)) 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 + }, [ + action, + isUsdtApprovalResetEnabled, + inputTokenAssetId, + yieldChainId, + approvalSpender, + allowanceQuery.data, + allowanceQuery.isError, + ]) + const updateStepStatus = useCallback((index: number, updates: Partial) => { setTransactionSteps(prev => prev.map((s, i) => (i === index ? { ...s, ...updates } : s))) }, []) @@ -328,10 +435,76 @@ export const useYieldTransactionFlow = ({ action, ]) + 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, + 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) { + console.error('Reset allowance failed:', 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, + accountId, + inputTokenAssetId, + approvalSpender, + accountNumber, + userAddress, + feeAsset?.explorerTxLink, + translate, + updateStepStatus, + toast, + allowanceQuery, + ]) + const executeSingleTransaction = useCallback( async ( tx: TransactionDto, - index: number, + yieldTxIndex: number, + uiStepIndex: number, allTransactions: TransactionDto[], actionId: string, ) => { @@ -339,7 +512,7 @@ export const useYieldTransactionFlow = ({ throw new Error(translate('yieldXYZ.errors.walletNotConnected')) } - updateStepStatus(index, { + updateStepStatus(uiStepIndex, { status: 'loading', loadingMessage: translate('yieldXYZ.loading.signInWallet'), }) @@ -360,7 +533,11 @@ export const useYieldTransactionFlow = ({ const txUrl = feeAsset ? `${feeAsset.explorerTxLink}${txHash}` : '' - updateStepStatus(index, { txHash, txUrl, loadingMessage: translate('common.confirming') }) + updateStepStatus(uiStepIndex, { + txHash, + txUrl, + loadingMessage: translate('common.confirming'), + }) await submitHashMutation.mutateAsync({ transactionId: tx.id, @@ -369,7 +546,7 @@ export const useYieldTransactionFlow = ({ address: userAddress, }) - const isLastTransaction = index + 1 >= allTransactions.length + const isLastTransaction = yieldTxIndex + 1 >= allTransactions.length if (isLastTransaction) { await waitForActionCompletion(actionId) @@ -388,18 +565,18 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } else { - const freshAction = await fetchAction(actionId) - const nextTx = freshAction.transactions.find( - t => t.status === TransactionStatus.Created && t.stepIndex === index + 1, + const confirmedAction = await waitForTransactionConfirmation(actionId, tx.id) + const nextTx = confirmedAction.transactions.find( + t => t.status === TransactionStatus.Created && t.stepIndex === yieldTxIndex + 1, ) if (nextTx) { - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) - setRawTransactions(prev => prev.map((t, i) => (i === index + 1 ? nextTx : t))) - setActiveStepIndex(index + 1) + 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'] }) @@ -417,7 +594,7 @@ export const useYieldTransactionFlow = ({ ) } dispatchNotification(tx, txHash) - updateStepStatus(index, { status: 'success', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'success', loadingMessage: undefined }) setStep(ModalStep.Success) } } @@ -427,7 +604,7 @@ export const useYieldTransactionFlow = ({ 'yieldXYZ.errors.transactionFailedTitle', 'yieldXYZ.errors.transactionFailedDescription', ) - updateStepStatus(index, { status: 'pending', loadingMessage: undefined }) + updateStepStatus(uiStepIndex, { status: 'failed', loadingMessage: undefined }) } finally { setIsSubmitting(false) } @@ -459,13 +636,29 @@ export const useYieldTransactionFlow = ({ setRawTransactions([]) setActiveStepIndex(-1) setCurrentActionId(null) + setResetTxHash(null) onClose() }, [isSubmitting, onClose, queryClient]) const handleConfirm = useCallback(async () => { - if (activeStepIndex >= 0 && rawTransactions[activeStepIndex] && currentActionId) { + // 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 + if (yieldStepIndex >= 0 && rawTransactions[yieldStepIndex] && currentActionId) { await executeSingleTransaction( - rawTransactions[activeStepIndex], + rawTransactions[yieldStepIndex], + yieldStepIndex, activeStepIndex, rawTransactions, currentActionId, @@ -521,17 +714,35 @@ export const useYieldTransactionFlow = ({ setCurrentActionId(quoteData.id) setRawTransactions(transactions) - setTransactionSteps( - transactions.map((tx, i) => ({ + + // 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', + }) + } + steps.push( + ...transactions.map((tx, i) => ({ title: formatYieldTxTitle(tx.title || `Transaction ${i + 1}`, assetSymbol), originalTitle: tx.title || '', type: tx.type, status: 'pending' as const, })), ) + + setTransactionSteps(steps) setActiveStepIndex(0) - await executeSingleTransaction(transactions[0], 0, transactions, quoteData.id) + // Execute first step (reset if required, otherwise first yield tx) + if (isUsdtResetRequired) { + await executeResetAllowance() + } else { + await executeSingleTransaction(transactions[0], 0, 0, transactions, quoteData.id) + } } catch (error) { console.error('Failed to initiate action:', error) showErrorToast( @@ -542,9 +753,13 @@ export const useYieldTransactionFlow = ({ setTransactionSteps([]) } }, [ + isUsdtResetRequired, activeStepIndex, - rawTransactions, + resetTxHash, currentActionId, + rawTransactions, + executeResetAllowance, + executeSingleTransaction, yieldChainId, wallet, accountId, @@ -555,7 +770,6 @@ export const useYieldTransactionFlow = ({ assetSymbol, translate, showErrorToast, - executeSingleTransaction, ]) return useMemo( @@ -569,6 +783,8 @@ export const useYieldTransactionFlow = ({ handleClose, isQuoteLoading, quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, }), [ step, @@ -580,6 +796,8 @@ export const useYieldTransactionFlow = ({ handleClose, isQuoteLoading, quoteData, + isAllowanceCheckPending, + isUsdtResetRequired, ], ) } diff --git a/src/react-queries/queries/yieldxyz/useYieldValidators.ts b/src/react-queries/queries/yieldxyz/useYieldValidators.ts index 0962b595ddf..0c0b5842ec4 100644 --- a/src/react-queries/queries/yieldxyz/useYieldValidators.ts +++ b/src/react-queries/queries/yieldxyz/useYieldValidators.ts @@ -1,7 +1,27 @@ import { skipToken, useQuery } from '@tanstack/react-query' import { fetchYieldValidators } from '@/lib/yieldxyz/api' +import { + COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + SHAPESHIFT_VALIDATOR, +} from '@/lib/yieldxyz/constants' import type { ValidatorDto } from '@/lib/yieldxyz/types' +import { ensureValidatorApr } from '@/lib/yieldxyz/utils' + +const normalizeCosmosValidators = (validators: ValidatorDto[]): ValidatorDto[] => { + const existingShapeshift = validators.find(v => v.address === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) + + const shapeshiftValidator: ValidatorDto = existingShapeshift?.rewardRate?.total + ? { ...existingShapeshift, preferred: true } + : { ...SHAPESHIFT_VALIDATOR, ...existingShapeshift, preferred: true } + + const otherValidators = validators + .filter(v => v.address !== SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) + .map(v => ensureValidatorApr({ ...v, preferred: false })) + + return [shapeshiftValidator, ...otherValidators] +} export const useYieldValidators = (yieldId: string, enabled: boolean = true) => { return useQuery({ @@ -10,7 +30,10 @@ export const useYieldValidators = (yieldId: string, enabled: boolean = true) => yieldId && enabled ? async () => { const data = await fetchYieldValidators(yieldId) - return data.items + const validators = data.items + if (yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID) + return normalizeCosmosValidators(validators) + return validators } : skipToken, staleTime: 1000 * 60 * 60, diff --git a/src/state/slices/preferencesSlice/preferencesSlice.ts b/src/state/slices/preferencesSlice/preferencesSlice.ts index 63c56c1aed2..3577b5a0669 100644 --- a/src/state/slices/preferencesSlice/preferencesSlice.ts +++ b/src/state/slices/preferencesSlice/preferencesSlice.ts @@ -111,6 +111,7 @@ export type FeatureFlags = { AddressBook: boolean AppRating: boolean YieldXyz: boolean + YieldsPage: boolean YieldMultiAccount: boolean } @@ -257,6 +258,7 @@ const initialState: Preferences = { AddressBook: getConfig().VITE_FEATURE_ADDRESS_BOOK, AppRating: getConfig().VITE_FEATURE_APP_RATING, YieldXyz: getConfig().VITE_FEATURE_YIELD_XYZ, + YieldsPage: getConfig().VITE_FEATURE_YIELDS_PAGE, YieldMultiAccount: getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT, }, selectedLocale: simpleLocale(), diff --git a/src/test/mocks/store.ts b/src/test/mocks/store.ts index 1b8a947958a..bec51689460 100644 --- a/src/test/mocks/store.ts +++ b/src/test/mocks/store.ts @@ -184,6 +184,7 @@ export const mockStore: ReduxState = { AddressBook: false, AppRating: false, YieldXyz: false, + YieldsPage: false, YieldMultiAccount: false, }, showTopAssetsCarousel: true, diff --git a/vite.config.mts b/vite.config.mts index 79406fea2a4..4234e54ca2b 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -168,6 +168,7 @@ export default defineConfig(({ mode }) => { port: 3000, headers, host: '0.0.0.0', + allowedHosts: true, proxy: { '/user-api': { target: 'http://localhost:3002',
- - {column.render('Header')} - - {column.isSorted ? ( - column.isSortedDesc ? ( - - ) : ( - - ) - ) : null} - - -
+ + {column.render('Header')} + + {column.isSorted ? ( + column.isSortedDesc ? ( + + ) : ( + + ) + ) : null} + + +
- - {renderAssetIcon(yieldItem)} - - {yieldItem.metadata.name} - - - {validator.logoURI ? ( @@ -180,17 +158,9 @@ export const YieldActivePositions = memo( return (
- - {renderAssetIcon(yieldItem)} - - {yieldItem.metadata.name} - - - ) }) - }, [ - activeYields, - aggregated, - asset, - getProviderLogo, - handleRowClick, - hoverBg, - renderAssetIcon, - userCurrencyToUsdRate, - ]) + }, [activeYields, aggregated, asset, getProviderLogo, handleRowClick, userCurrencyToUsdRate]) if (!asset) return null if (activeYields.length === 0) return null return ( - - - {yourBalanceLabel} - - - - - - - - - - - - - {tableRows} -
{assetColumnHeader}{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
-
-
+ + + + + + + + + + + {tableRows} +
{providerColumnHeader}{apyColumnHeader}{tvlColumnHeader}{balanceColumnHeader}
+
) }, ) diff --git a/src/pages/Yields/components/YieldAssetSection.tsx b/src/pages/Yields/components/YieldAssetSection.tsx index 636e039abf5..fdeb1c864c0 100644 --- a/src/pages/Yields/components/YieldAssetSection.tsx +++ b/src/pages/Yields/components/YieldAssetSection.tsx @@ -1,22 +1,28 @@ -import { Box, Heading, Stack, VStack } from '@chakra-ui/react' +import { Card, CardBody, CardHeader, Heading, Stack, VStack } from '@chakra-ui/react' import type { AccountId, AssetId } from '@shapeshiftoss/caip' import { fromAccountId } from '@shapeshiftoss/caip' -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' -import { useNavigate } from 'react-router-dom' import { YieldActivePositions } from './YieldActivePositions' +import { YieldEnterModal } from './YieldEnterModal' import { YieldItemSkeleton } from './YieldItem' import { YieldOpportunityCard } from './YieldOpportunityCard' import { getConfig } from '@/config' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' +import { useWallet } from '@/hooks/useWallet/useWallet' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import type { YieldBalanceAggregate } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' -import { selectAssetById } from '@/state/slices/selectors' -import { useAppSelector } from '@/state/store' + +const LoadingContent = ( + + + + +) type YieldAssetSectionProps = { assetId: AssetId @@ -25,23 +31,27 @@ type YieldAssetSectionProps = { export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSectionProps) => { const translate = useTranslate() - const navigate = useNavigate() const isYieldXyzEnabled = useFeatureFlag('YieldXyz') - const asset = useAppSelector(state => selectAssetById(state, assetId)) + const { + state: { isConnected }, + } = useWallet() const { data: yieldsData, isLoading: isYieldsLoading } = useYields() const balanceOptions = useMemo(() => (accountId ? { accountIds: [accountId] } : {}), [accountId]) const { data: allBalancesData, isLoading: isBalancesLoading } = useAllYieldBalances(balanceOptions) const isLoading = isYieldsLoading || isBalancesLoading + const [isEnterModalOpen, setIsEnterModalOpen] = useState(false) + const [selectedYield, setSelectedYield] = useState(null) + const yields = useMemo(() => { - if (!yieldsData?.all || !asset) return [] + if (!yieldsData?.all) return [] return yieldsData.all.filter(yieldItem => { const matchesToken = yieldItem.token.assetId === assetId const matchesInput = yieldItem.inputTokens.some(t => t.assetId === assetId) return matchesToken || matchesInput }) - }, [yieldsData, asset, assetId]) + }, [yieldsData, assetId]) const aggregated = useMemo(() => { const multiAccountEnabled = getConfig().VITE_FEATURE_YIELD_MULTI_ACCOUNT @@ -78,48 +88,45 @@ export const YieldAssetSection = memo(({ assetId, accountId }: YieldAssetSection const hasActivePositions = Object.keys(aggregated).length > 0 - const handleOpportunityClick = useCallback( - (yieldItem: AugmentedYieldDto) => { - navigate(`/yields/${yieldItem.id}`) - }, - [navigate], - ) - - const yieldHeading = translate('yieldXYZ.yield') ?? 'Yield' - - const loadingContent = useMemo( - () => ( - - - - - ), - [], - ) - - const activePositionsContent = useMemo( - () => , - [aggregated, yields, assetId], - ) + const handleOpportunityClick = useCallback((yieldItem: AugmentedYieldDto) => { + setSelectedYield(yieldItem) + setIsEnterModalOpen(true) + }, []) - const opportunityCardContent = useMemo(() => { - if (!bestYield) return null - return - }, [bestYield, handleOpportunityClick]) + const handleEnterModalClose = useCallback(() => { + setIsEnterModalOpen(false) + setSelectedYield(null) + }, []) if (!isYieldXyzEnabled) return null + if (!isConnected) return null if (!isLoading && yields.length === 0) return null return ( - - - {yieldHeading} - - - {hasActivePositions && activePositionsContent} - {isLoading && loadingContent} - {!isLoading && !hasActivePositions && opportunityCardContent} - - + <> + + + {translate('yieldXYZ.yield')} + + + + {hasActivePositions && ( + + )} + {isLoading && LoadingContent} + {!isLoading && !hasActivePositions && bestYield && ( + + )} + + + + {selectedYield && ( + + )} + ) }) diff --git a/src/pages/Yields/components/YieldEnterExit.tsx b/src/pages/Yields/components/YieldEnterExit.tsx index 14578245e42..71b93711b7e 100644 --- a/src/pages/Yields/components/YieldEnterExit.tsx +++ b/src/pages/Yields/components/YieldEnterExit.tsx @@ -3,6 +3,8 @@ import { Avatar, Box, Button, + Card, + CardBody, Flex, Icon, Skeleton, @@ -12,7 +14,6 @@ import { TabPanels, Tabs, Text, - useColorModeValue, } from '@chakra-ui/react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { FaMoneyBillWave } from 'react-icons/fa' @@ -59,8 +60,9 @@ const YieldEnterExitSkeleton = memo(() => ( )) -const moneyBillWaveIcon = -const chevronDownIcon = +const moneyBillWaveIcon = +const chevronDownIcon = +const buttonHoverSx = { transform: 'translateY(-1px)', boxShadow: 'lg' } export const YieldEnterExit = memo( ({ yieldItem, isQuoteLoading, balances, isBalancesLoading }: YieldEnterExitProps) => { @@ -69,13 +71,6 @@ export const YieldEnterExit = memo( const { accountNumber } = useYieldAccount() const { state: walletState, dispatch } = useWallet() const isConnected = useMemo(() => Boolean(walletState.walletInfo), [walletState.walletInfo]) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const validatorPickerBg = useColorModeValue('gray.50', 'blackAlpha.50') - const validatorPickerHoverBg = useColorModeValue('gray.100', 'whiteAlpha.100') - const tabListBg = useColorModeValue('gray.50', 'blackAlpha.200') - const estimatedEarningsBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const estimatedEarningsBorderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const initialTab = useMemo(() => { if (location.pathname.endsWith('/exit')) return 1 @@ -278,20 +273,6 @@ export const YieldEnterExit = memo( const uniqueValidatorCount = balances ? balances.validatorAddresses.length : 0 const shouldShowValidatorPicker = uniqueValidatorCount > 1 - const enterTabSelectedStyle = useMemo( - () => ({ - color: 'blue.400', - bg: cardBg, - borderBottomColor: cardBg, - borderTopColor: 'blue.400', - borderTopWidth: 2, - }), - [cardBg], - ) - - const tabFocusStyle = useMemo(() => ({ boxShadow: 'none' }), []) - const buttonHoverStyle = useMemo(() => ({ transform: 'translateY(-1px)', boxShadow: 'lg' }), []) - const enterButtonDisabled = useMemo( () => isConnected && @@ -326,16 +307,6 @@ export const YieldEnterExit = memo( return translate('common.connectWallet') }, [isConnected, translate]) - const handleEnterButtonClick = useMemo( - () => (isConnected ? handleEnterClick : handleConnectWallet), - [isConnected, handleEnterClick, handleConnectWallet], - ) - - const handleExitButtonClick = useMemo( - () => (isConnected ? handleExitClick : handleConnectWallet), - [isConnected, handleExitClick, handleConnectWallet], - ) - const modalAssetSymbol = useMemo( () => (modalAction === 'enter' ? inputToken?.symbol ?? '' : yieldItem.token.symbol), [modalAction, inputToken?.symbol, yieldItem.token.symbol], @@ -343,8 +314,6 @@ export const YieldEnterExit = memo( const enterTabDisabled = !yieldItem.status.enter const exitTabDisabled = !yieldItem.status.exit - const enterTabOpacity = enterTabDisabled ? 0.5 : 1 - const exitTabOpacity = exitTabDisabled ? 0.5 : 1 const isPreferredValidator = useMemo( () => (validatorMetadata as ValidatorDto | undefined)?.preferred === true, @@ -376,9 +345,9 @@ export const YieldEnterExit = memo( {moneyBillWaveIcon} - - {translate('yieldXYZ.minDeposit')} + + {translate('yieldXYZ.minEnter')} - + {minDeposit} {inputToken?.symbol} @@ -475,7 +441,7 @@ export const YieldEnterExit = memo( {estimatedYearlyEarningsDisplay} - + @@ -549,108 +515,105 @@ export const YieldEnterExit = memo( return ( <> - - {validatorPickerContent} - - - - {translate('yieldXYZ.enter')} - - - {translate('yieldXYZ.exit')} - - - - - - {enterTabPanelContent} - {minDepositContent} - - - - {translate('yieldXYZ.currentApy')} - - - {apyDisplay} - - - {estimatedYearlyEarningsContent} - - - - - - - {exitTabPanelContent} - - - - - - + + + {validatorPickerContent} + + + + {translate('yieldXYZ.enter')} + + + {translate('yieldXYZ.exit')} + + + + + + {enterTabPanelContent} + {minDepositContent} + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {estimatedYearlyEarningsContent} + + + + + + + {exitTabPanelContent} + + + + + + + void + yieldItem: AugmentedYieldDto + 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' + +const INPUT_LENGTH_BREAKPOINTS = { + FOR_XS_FONT: 22, + FOR_SM_FONT: 14, + FOR_MD_FONT: 10, +} as const + +const getInputFontSize = (length: number): string => { + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_XS_FONT) return '24px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_SM_FONT) return '30px' + if (length >= INPUT_LENGTH_BREAKPOINTS.FOR_MD_FONT) return '38px' + return '48px' +} + +const selectedHoverSx = { bg: 'blue.600' } +const unselectedHoverSx = { bg: 'background.surface.raised.hover' } + +type CryptoAmountInputProps = { + value?: string + onChange?: (e: React.ChangeEvent) => void + placeholder?: string + [key: string]: unknown +} + +const CryptoAmountInput = (props: CryptoAmountInputProps) => { + const valueLength = useMemo(() => (props.value ? String(props.value).length : 0), [props.value]) + const fontSize = useMemo(() => getInputFontSize(valueLength), [valueLength]) + + return ( + + ) +} + +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] + const inputTokenAssetId = inputToken?.assetId + + const accountIdFilter = useMemo( + () => ({ assetId: inputTokenAssetId ?? '' }), + [inputTokenAssetId], + ) + const accountIds = useAppSelector(state => + selectPortfolioAccountIdsByAssetIdFilter(state, accountIdFilter), + ) + + const defaultAccountId = useAppSelector(state => { + if (!chainId) return undefined + const accountIdsByNumberAndChain = selectAccountIdByAccountNumberAndChainId(state) + return accountIdsByNumberAndChain[accountNumber]?.[chainId] + }) + + const accountId = selectedAccountId ?? defaultAccountId + const hasMultipleAccounts = accountIds.length > 1 + const isAccountSelectorDisabled = !isYieldMultiAccountEnabled || !hasMultipleAccounts + + const shouldFetchValidators = useMemo( + () => + yieldItem.mechanics.type === 'staking' && yieldItem.mechanics.requiresValidatorSelection, + [yieldItem.mechanics.type, yieldItem.mechanics.requiresValidatorSelection], + ) + const { data: validators, isLoading: isValidatorsLoading } = useYieldValidators( + yieldItem.id, + shouldFetchValidators, + ) + + const selectedValidatorAddress = useMemo(() => { + if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { + return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + } + return validators?.[0]?.address + }, [chainId, validators]) + + const { data: providers } = useYieldProviders() + + const isStaking = yieldItem.mechanics.type === 'staking' + + const selectedValidatorMetadata = useMemo(() => { + if (!isStaking || !selectedValidatorAddress) return null + const found = validators?.find(v => v.address === selectedValidatorAddress) + if (found) return found + if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { + return { + name: SHAPESHIFT_VALIDATOR_NAME, + logoURI: SHAPESHIFT_VALIDATOR_LOGO, + address: selectedValidatorAddress, + } + } + return null + }, [isStaking, selectedValidatorAddress, validators]) + + const providerMetadata = useMemo(() => { + if (!providers) return null + 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 => + inputTokenAssetId && accountId + ? selectPortfolioCryptoPrecisionBalanceByFilter(state, { + assetId: inputTokenAssetId, + accountId, + }) + : '0', + ) + + const marketData = useAppSelector(state => + 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(() => { + if (!cryptoAmount || !minDeposit) return false + 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), + [cryptoAmount, marketData?.price], + ) + + const apy = useMemo(() => bnOrZero(yieldItem.rewardRate.total), [yieldItem.rewardRate.total]) + const apyDisplay = useMemo(() => `${apy.times(100).toFixed(2)}%`, [apy]) + + const estimatedYearlyEarnings = useMemo( + () => bnOrZero(cryptoAmount).times(apy), + [cryptoAmount, apy], + ) + + const estimatedYearlyEarningsFiat = useMemo( + () => estimatedYearlyEarnings.times(marketData?.price ?? 0), + [estimatedYearlyEarnings, marketData?.price], + ) + + const hasAmount = bnOrZero(cryptoAmount).gt(0) + + const displayPlaceholder = useMemo( + () => (isFiat ? `${localeParts.prefix}0` : '0'), + [isFiat, localeParts.prefix], + ) + + const handleInputChange = useCallback( + (values: NumberFormatValues) => { + setSelectedPercent(null) + if (isFiat) { + const crypto = bnOrZero(values.value) + .div(marketData?.price ?? 1) + .toFixed() + setCryptoAmount(crypto) + } else { + setCryptoAmount(values.value) + } + }, + [isFiat, marketData?.price], + ) + + const displayValue = useMemo(() => { + if (isFiat) { + return fiatAmount.toFixed(2) + } + return cryptoAmount + }, [isFiat, fiatAmount, cryptoAmount]) + + const toggleIsFiat = useCallback(() => setIsFiat(prev => !prev), []) + + const handlePercentClick = useCallback( + (percent: number) => { + const percentAmount = bnOrZero(inputTokenBalance).times(percent).toFixed() + setCryptoAmount(percentAmount) + setSelectedPercent(percent) + }, + [inputTokenBalance], + ) + + const handleConnectWallet = useCallback( + () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), + [walletDispatch], + ) + + 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]) + + const handleAccountChange = useCallback((newAccountId: string) => { + setSelectedAccountId(newAccountId) + setCryptoAmount('') + 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, + 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) + + if (transactions.length === 0) { + setModalStep('success') + return + } + + 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', + }) + } + + 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) + } + }, [ + isUsdtResetRequired, + activeStepIndex, + resetTxHash, + executeResetAllowance, + rawTransactions, + currentActionId, + wallet, + accountId, + chainId, + quoteData, + inputTokenAsset, + translate, + executeSingleTransaction, + ]) + + const enterButtonDisabled = useMemo( + () => + isConnected && + (isLoading || !yieldItem.status.enter || !cryptoAmount || isBelowMinimum || !quoteData), + [isConnected, isLoading, yieldItem.status.enter, cryptoAmount, isBelowMinimum, quoteData], + ) + + const enterButtonText = useMemo(() => { + 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, + ) + if (firstCreatedTx) return getTransactionButtonText(firstCreatedTx.type, firstCreatedTx.title) + + // Fallback to generic enter text + return translate('yieldXYZ.enterAsset', { asset: inputTokenAsset?.symbol }) + }, [ + isConnected, + isQuoteActive, + isSubmitting, + transactionSteps, + activeStepIndex, + isUsdtResetRequired, + quoteData, + translate, + inputTokenAsset?.symbol, + ]) + + const modalTitle = useMemo(() => { + if (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]) + + const percentButtons = useMemo( + () => ( + + {PRESET_PERCENTAGES.map(percent => { + const isSelected = selectedPercent === percent + return ( + + ) + })} + + ), + [selectedPercent, handlePercentClick, translate], + ) + + const statsContent = useMemo( + () => ( + + + + {translate('yieldXYZ.currentApy')} + + + {apyDisplay} + + + {hasAmount && ( + + + {translate('yieldXYZ.estYearlyEarnings')} + + + + {estimatedYearlyEarnings.decimalPlaces(4).toString()} {inputTokenAsset?.symbol} + + + + + + + )} + {isStaking && selectedValidatorMetadata && ( + + + {translate('yieldXYZ.validator')} + + + + + {selectedValidatorMetadata.name} + + + + )} + {!isStaking && providerMetadata && ( + + + {translate('yieldXYZ.provider')} + + + + + {providerMetadata.name} + + + + )} + {minDeposit && bnOrZero(minDeposit).gt(0) && ( + + + {translate('yieldXYZ.minEnter')} + + + {minDeposit} {inputTokenAsset?.symbol} + + + )} + + ), + [ + translate, + apyDisplay, + hasAmount, + estimatedYearlyEarnings, + inputTokenAsset?.symbol, + estimatedYearlyEarningsFiat, + isStaking, + selectedValidatorMetadata, + providerMetadata, + minDeposit, + isBelowMinimum, + ], + ) + + const inputContent = useMemo(() => { + if (isLoading) return + + return ( + + {inputTokenAssetId && } + + + + {isFiat ? ( + + ) : ( + + )} + + + + + ) + }, [ + isLoading, + inputTokenAssetId, + isFiat, + inputTokenAsset?.precision, + localeParts, + displayValue, + displayPlaceholder, + inputTokenAsset?.symbol, + handleInputChange, + toggleIsFiat, + cryptoAmount, + 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 successContent = useMemo( + () => ( + + + + + + + {translate('yieldXYZ.success')} + + + {translate('yieldXYZ.successEnter', { + amount: cryptoAmount, + symbol: inputTokenAsset?.symbol, + })} + + + {successProviderInfo && ( + + + + {successProviderInfo.name} + + + )} + + + + + ), + [translate, cryptoAmount, inputTokenAsset?.symbol, successProviderInfo, transactionSteps], + ) + + return ( + <> + + + + {null} + + {modalTitle} + + + + + + + {modalStep === 'input' && ( + + {inputContent} + {percentButtons} + {inputTokenAssetId && accountId && ( + + + + )} + {statsContent} + {activeStepIndex >= 0 ? ( + + ) : ( + previewSteps.length > 0 && + )} + + )} + {modalStep === 'success' && successContent} + + {modalStep === 'input' && ( + + + + )} + {modalStep === 'success' && ( + + + + )} + + + ) + }, +) diff --git a/src/pages/Yields/components/YieldFilters.tsx b/src/pages/Yields/components/YieldFilters.tsx index 073c85b4c26..9838eb56fb0 100644 --- a/src/pages/Yields/components/YieldFilters.tsx +++ b/src/pages/Yields/components/YieldFilters.tsx @@ -6,12 +6,12 @@ import { IconButton, Menu, MenuButton, - MenuItem, + MenuItemOption, MenuList, + MenuOptionGroup, Stack, Text, Tooltip, - useColorModeValue, } from '@chakra-ui/react' import type { ChainId } from '@shapeshiftoss/caip' import React, { memo, useCallback, useMemo } from 'react' @@ -51,70 +51,44 @@ type FilterMenuProps = { const chevronDownIcon = +const ALL_OPTION_VALUE = '__all__' + const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: FilterMenuProps) => { const selectedOption = useMemo(() => options.find(o => o.id === value), [options, value]) const displayLabel = useMemo( () => (selectedOption ? selectedOption.name : label), [selectedOption, label], ) - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') - const selectedColor = useColorModeValue('blue.500', 'blue.300') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const activeBg = useColorModeValue('gray.100', 'gray.700') - - const handleSelectAll = useCallback(() => onSelect(null), [onSelect]) - - const hoverStyle = useMemo(() => ({ bg: hoverBg }), [hoverBg]) - const activeStyle = useMemo(() => ({ bg: activeBg }), [activeBg]) const selectedIcon = useMemo( () => (selectedOption && renderIcon ? renderIcon(selectedOption) : null), [selectedOption, renderIcon], ) - const allItemColor = useMemo( - () => (value === null ? selectedColor : undefined), - [value, selectedColor], + const handleChange = useCallback( + (newValue: string | string[]) => { + const selectedValue = Array.isArray(newValue) ? newValue[0] : newValue + onSelect(selectedValue === ALL_OPTION_VALUE ? null : selectedValue) + }, + [onSelect], ) - const allItemFontWeight = useMemo(() => (value === null ? 'semibold' : undefined), [value]) const menuItems = useMemo( () => - options.map(opt => { - const isSelected = value === opt.id - return ( - onSelect(opt.id)} - color={isSelected ? selectedColor : undefined} - fontWeight={isSelected ? 'semibold' : undefined} - > - - {renderIcon && renderIcon(opt)} - {opt.name} - - - ) - }), - [options, value, selectedColor, renderIcon, onSelect], + options.map(opt => ( + + + {renderIcon && renderIcon(opt)} + {opt.name} + + + )), + [options, renderIcon], ) return ( - + {selectedIcon} @@ -122,11 +96,11 @@ const FilterMenu = memo(({ label, value, options, onSelect, renderIcon }: Filter - - - {label} - - {menuItems} + + + {label} + {menuItems} + ) @@ -156,10 +130,6 @@ export const YieldFilters = memo( ...props }: YieldFiltersProps) => { const translate = useTranslate() - const bg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.200', 'gray.700') - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const activeBg = useColorModeValue('gray.100', 'gray.700') const sortOptions = useMemo( () => [ @@ -196,26 +166,31 @@ export const YieldFilters = memo( return }, [sortOption]) - const hoverStyle = useMemo(() => ({ bg: hoverBg }), [hoverBg]) - const activeStyle = useMemo(() => ({ bg: activeBg }), [activeBg]) + const handleSortChange = useCallback( + (newValue: string | string[]) => { + const selectedValue = Array.isArray(newValue) ? newValue[0] : newValue + onSortChange(selectedValue as SortOption) + }, + [onSortChange], + ) const sortMenuItems = useMemo( () => sortOptions.map(opt => ( - onSortChange(opt.value)} - color={sortOption === opt.value ? 'blue.500' : 'inherit'} - fontWeight={sortOption === opt.value ? 'bold' : 'normal'} - > + {opt.label} - + )), - [sortOptions, sortOption, onSortChange], + [sortOptions], ) return ( - + - - {sortMenuItems} + + + {sortMenuItems} + diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index 72da2e25d23..1be7df90557 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -12,7 +12,6 @@ import { StatLabel, StatNumber, Text, - useColorModeValue, } from '@chakra-ui/react' import type BigNumber from 'bignumber.js' import { memo, useCallback, useMemo } from 'react' @@ -59,13 +58,6 @@ export const YieldItem = memo( const userCurrencyToUsdRate = useAppSelector(selectUserCurrencyToUsdRate) const { data: yieldProviders } = useYieldProviders() - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') - const hoverBorderColor = useColorModeValue('blue.500', 'blue.400') - const hoverBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const cardShadow = useColorModeValue('sm', 'none') - const cardHoverShadow = useColorModeValue('lg', 'lg') - const isSingle = data.type === 'single' const isGroup = data.type === 'group' @@ -135,49 +127,16 @@ export const YieldItem = memo( const iconSource = resolveYieldInputAssetIcon(data.yieldItem) const size = variant === 'card' ? 'md' : 'sm' if (iconSource.assetId) { - return ( - - ) + return } - return ( - - ) + return } const size = variant === 'card' ? 'md' : 'sm' if (data.assetId) { - return ( - - ) + return } - return ( - - ) - }, [data, isSingle, variant, borderColor]) + return + }, [data, isSingle, variant]) const subtitle = useMemo(() => { if (isSingle) { @@ -198,9 +157,9 @@ export const YieldItem = memo( @@ -266,20 +225,14 @@ export const YieldItem = memo( return ( {isGroup && ( - + @@ -392,14 +345,11 @@ export const YieldItem = memo( ) export const YieldItemSkeleton = memo(({ variant }: { variant: 'card' | 'row' }) => { - const borderColor = useColorModeValue('gray.100', 'gray.750') - const cardBg = useColorModeValue('white', 'gray.800') - if (variant === 'row') { return ( + diff --git a/src/pages/Yields/components/YieldOpportunityCard.tsx b/src/pages/Yields/components/YieldOpportunityCard.tsx index 5dc0e10a6ae..142ba8afb2a 100644 --- a/src/pages/Yields/components/YieldOpportunityCard.tsx +++ b/src/pages/Yields/components/YieldOpportunityCard.tsx @@ -1,9 +1,11 @@ -import { Box, Button, Flex, Heading, Text, useColorModeValue } from '@chakra-ui/react' +import { Box, Button, Flex } from '@chakra-ui/react' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { Text } from '@/components/Text/Text' import { bnOrZero } from '@/lib/bignumber/bignumber' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { GradientApy } from '@/pages/Yields/components/GradientApy' type YieldOpportunityCardProps = { maxApyYield: AugmentedYieldDto @@ -14,51 +16,50 @@ const hoverStyle = { bgGradient: 'linear(to-r, blue.600, purple.700)' } export const YieldOpportunityCard = memo(({ maxApyYield, onClick }: YieldOpportunityCardProps) => { const translate = useTranslate() - const bg = useColorModeValue('gray.50', 'whiteAlpha.100') - const borderColor = useColorModeValue('gray.100', 'whiteAlpha.100') const apy = useMemo( () => bnOrZero(maxApyYield.rewardRate.total).times(100).toFixed(2), [maxApyYield.rewardRate.total], ) - const earnUpToText = useMemo(() => translate('yieldXYZ.earnUpTo', { apy }), [translate, apy]) - const startEarningText = useMemo(() => translate('yieldXYZ.startEarning'), [translate]) const handleClick = useCallback(() => { onClick(maxApyYield) }, [onClick, maxApyYield]) + const apyComponent = useMemo( + () => ( + + {apy}% + + ), + [apy], + ) + return ( - - - - {earnUpToText} - - - {apy}% APY - + + + - )} -
- + + + + + {translate('yieldXYZ.claimable')} + + + {formatBalance(claimableBalance)} + + + + + {translate('yieldXYZ.reward')} + + {claimAction && ( + + )} + + + ) }, [ hasClaimable, - claimableBg, - claimableBorderColor, - claimableTextColor, translate, formatBalance, claimableBalance, @@ -443,25 +339,17 @@ export const YieldPositionCard = memo( const addressBadge = useMemo(() => { if (!address) return null return ( - + {addressBadgeText} ) - }, [address, badgeBg, badgeColor, addressBadgeText]) + }, [address, addressBadgeText]) const pendingActionsSection = useMemo(() => { if (!showPendingActions) return null return ( <> - + {enteringSection} {exitingSection} @@ -470,24 +358,11 @@ export const YieldPositionCard = memo( ) - }, [ - showPendingActions, - borderColor, - enteringSection, - exitingSection, - withdrawableSection, - claimableSection, - ]) + }, [showPendingActions, enteringSection, exitingSection, withdrawableSection, claimableSection]) if (isBalancesLoading) { return ( - + + { const inputTokenMarketData = useAppSelector(state => selectMarketDataByAssetIdUserCurrency(state, inputTokenAssetId), ) - const cardBg = useColorModeValue('white', 'gray.800') - const borderColor = useColorModeValue('gray.100', 'gray.750') - const rewardBreakdownBg = useColorModeValue('gray.50', 'whiteAlpha.50') - const dividerColor = useColorModeValue('gray.200', 'whiteAlpha.100') const [searchParams] = useSearchParams() const validatorParam = useMemo(() => searchParams.get('validator'), [searchParams]) @@ -128,7 +123,14 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { const rewardBreakdownContent = useMemo(() => { if (yieldItem.rewardRate.components.length === 0) return null return ( - + {yieldItem.rewardRate.components.map((component, idx) => ( @@ -144,7 +146,7 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { ))} ) - }, [yieldItem.rewardRate.components, rewardBreakdownBg]) + }, [yieldItem.rewardRate.components]) const validatorRowContent = useMemo(() => { if (!validatorMetadata) return null @@ -172,7 +174,7 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { {moneyBillWaveIcon} - {translate('yieldXYZ.minDeposit')} + {translate('yieldXYZ.minEnter')} { }, [yieldItem.mechanics.entryLimits.minimum, yieldItem.token.symbol, translate]) return ( - + { {rewardBreakdownContent} - + {translate('yieldXYZ.tvl')} diff --git a/src/pages/Yields/components/YieldTable.tsx b/src/pages/Yields/components/YieldTable.tsx index 239576bbf49..ed00865b474 100644 --- a/src/pages/Yields/components/YieldTable.tsx +++ b/src/pages/Yields/components/YieldTable.tsx @@ -1,15 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon } from '@chakra-ui/icons' -import { - Flex, - Skeleton, - Table, - Tbody, - Td, - Th, - Thead, - Tr, - useColorModeValue, -} from '@chakra-ui/react' +import { Flex, Skeleton, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react' import type { Row, Table as TanstackTable } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table' import { memo, useCallback, useMemo } from 'react' @@ -32,9 +22,6 @@ const tableSize = { base: 'sm', md: 'md' } const SKELETON_ROWS = 6 export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProps) => { - const hoverBg = useColorModeValue('gray.50', 'gray.750') - const hoverColor = useColorModeValue('black', 'white') - const columns = useMemo(() => table.getAllColumns(), [table]) const headerGroups = useMemo(() => table.getHeaderGroups(), [table]) const rows = useMemo(() => table.getRowModel().rows, [table]) @@ -70,7 +57,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp key={row.id} cursor={isClickable ? 'pointer' : undefined} onClick={() => handleRowClick(row)} - _hover={isClickable ? { bg: hoverBg } : undefined} + _hover={isClickable ? { bg: 'background.surface.raised.base' } : undefined} > {row.getVisibleCells().map(cell => { const meta = cell.column.columnDef.meta as YieldColumnMeta | undefined @@ -83,7 +70,7 @@ export const YieldTable = memo(({ table, isLoading, onRowClick }: YieldTableProp