handleRowClick(yieldItem.id, validator.address)}
>
- |
-
- {renderAssetIcon(yieldItem)}
-
- {yieldItem.metadata.name}
-
-
- |
{validator.logoURI ? (
@@ -180,17 +158,9 @@ export const YieldActivePositions = memo(
return (
handleRowClick(yieldItem.id)}
>
- |
-
- {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}
-
-
-
-
-
- | {assetColumnHeader} |
- {providerColumnHeader} |
- {apyColumnHeader} |
- {tvlColumnHeader} |
- {balanceColumnHeader} |
-
-
- {tableRows}
-
-
-
+
+
+
+
+ | {providerColumnHeader} |
+ {apyColumnHeader} |
+ {tvlColumnHeader} |
+ {balanceColumnHeader} |
+
+
+ {tableRows}
+
+
)
},
)
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 (
+ <>
+
+
+ >
+ )
+ },
+)
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 (
-
- )
- }),
- [options, value, selectedColor, renderIcon, onSelect],
+ options.map(opt => (
+
+
+ {renderIcon && renderIcon(opt)}
+ {opt.name}
+
+
+ )),
+ [options, renderIcon],
)
return (
)
@@ -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 => (
-
+
)),
- [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.activeDeposits')}
+ {translate('yieldXYZ.activePositions')}
diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx
index 645f3e1eb3e..d47fb561843 100644
--- a/src/pages/Yields/components/YieldPositionCard.tsx
+++ b/src/pages/Yields/components/YieldPositionCard.tsx
@@ -12,7 +12,6 @@ import {
HStack,
Skeleton,
Text,
- useColorModeValue,
VStack,
} from '@chakra-ui/react'
import { fromAccountId } from '@shapeshiftoss/caip'
@@ -60,26 +59,6 @@ export const YieldPositionCard = memo(
({ yieldItem, balances, isBalancesLoading }: YieldPositionCardProps) => {
const [claimModalData, setClaimModalData] = useState(null)
const translate = useTranslate()
- const cardBg = useColorModeValue('white', 'gray.800')
- const borderColor = useColorModeValue('gray.100', 'gray.750')
- const badgeBg = useColorModeValue('blue.50', 'blue.900')
- const badgeColor = useColorModeValue('blue.700', 'blue.200')
- const emptyStateBg = useColorModeValue('blue.50', 'blue.900')
- const emptyStateBorderColor = useColorModeValue('blue.200', 'blue.800')
- const emptyStateTitleColor = useColorModeValue('blue.700', 'blue.100')
- const emptyStateTextColor = useColorModeValue('blue.600', 'blue.200')
- const enteringBg = useColorModeValue('yellow.50', 'yellow.900')
- const enteringBorderColor = useColorModeValue('yellow.300', 'yellow.700')
- const enteringTextColor = useColorModeValue('yellow.700', 'yellow.300')
- const exitingBg = useColorModeValue('orange.50', 'orange.900')
- const exitingBorderColor = useColorModeValue('orange.300', 'orange.700')
- const exitingTextColor = useColorModeValue('orange.700', 'orange.300')
- const withdrawableBg = useColorModeValue('green.50', 'green.900')
- const withdrawableBorderColor = useColorModeValue('green.300', 'green.700')
- const withdrawableTextColor = useColorModeValue('green.700', 'green.300')
- const claimableBg = useColorModeValue('purple.50', 'purple.900')
- const claimableBorderColor = useColorModeValue('purple.300', 'purple.700')
- const claimableTextColor = useColorModeValue('purple.700', 'purple.300')
const [searchParams] = useSearchParams()
const validatorParam = searchParams.get('validator')
@@ -239,199 +218,116 @@ export const YieldPositionCard = memo(
flexDirection='column'
alignItems='start'
p={4}
- bg={emptyStateBg}
- borderColor={emptyStateBorderColor}
- border='1px solid'
>
-
-
- {translate('yieldXYZ.startEarning')}
-
+
+ {translate('yieldXYZ.startEarning')}
-
- {translate('yieldXYZ.depositYourToken', { symbol: yieldItem.token.symbol })}
+
+ {translate('yieldXYZ.enterYourToken', { symbol: yieldItem.token.symbol })}
),
- [
- emptyStateBg,
- emptyStateBorderColor,
- emptyStateTextColor,
- emptyStateTitleColor,
- yieldItem.token.symbol,
- translate,
- ],
+ [yieldItem.token.symbol, translate],
)
const enteringSection = useMemo(() => {
if (!hasEntering) return null
return (
-
-
-
- {translate('yieldXYZ.entering')}
-
-
- {formatBalance(enteringBalance)}
-
-
-
- {translate('yieldXYZ.pending')}
-
-
+
+
+
+
+ {translate('yieldXYZ.entering')}
+
+
+ {formatBalance(enteringBalance)}
+
+
+
+ {translate('yieldXYZ.pending')}
+
+
+
)
- }, [
- hasEntering,
- enteringBg,
- enteringBorderColor,
- enteringTextColor,
- translate,
- formatBalance,
- enteringBalance,
- ])
+ }, [hasEntering, translate, formatBalance, enteringBalance])
const exitingSection = useMemo(() => {
if (!hasExiting) return null
return (
-
-
-
- {translate('yieldXYZ.exiting')}
-
-
- {formatBalance(exitingBalance)}
-
-
-
- {translate('yieldXYZ.pending')}
-
-
+
+
+
+
+ {translate('yieldXYZ.exiting')}
+
+
+ {formatBalance(exitingBalance)}
+
+
+
+ {translate('yieldXYZ.pending')}
+
+
+
)
- }, [
- hasExiting,
- exitingBg,
- exitingBorderColor,
- exitingTextColor,
- translate,
- formatBalance,
- exitingBalance,
- ])
+ }, [hasExiting, translate, formatBalance, exitingBalance])
const withdrawableSection = useMemo(() => {
if (!hasWithdrawable) return null
return (
-
-
-
- {translate('yieldXYZ.withdrawable')}
-
-
- {formatBalance(withdrawableBalance)}
-
-
-
- {translate('yieldXYZ.ready')}
-
-
+
+
+
+
+ {translate('yieldXYZ.withdrawable')}
+
+
+ {formatBalance(withdrawableBalance)}
+
+
+
+ {translate('yieldXYZ.ready')}
+
+
+
)
- }, [
- hasWithdrawable,
- withdrawableBg,
- withdrawableBorderColor,
- withdrawableTextColor,
- translate,
- formatBalance,
- withdrawableBalance,
- ])
+ }, [hasWithdrawable, translate, formatBalance, withdrawableBalance])
const claimableSection = useMemo(() => {
if (!hasClaimable) return null
return (
-
-
-
- {translate('yieldXYZ.claimable')}
-
-
- {formatBalance(claimableBalance)}
-
-
-
-
- {translate('yieldXYZ.reward')}
-
- {claimAction && (
-
- )}
-
-
+
+
+
+
+ {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
|
)
}),
- [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',
|