diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index d6c1137ff72..9f9ef048e58 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2679,7 +2679,6 @@ "asset": "Asset", "provider": "Provider", "balance": "Balance", - "yourBalance": "Your Balance", "noYields": "No yield opportunities available", "connectWallet": "Connect a wallet to view yields", "stats": "Stats", @@ -2743,7 +2742,6 @@ "network": "Network", "market": "market", "markets": "markets", - "protocol": "protocol", "chain": "chain", "chains": "chains", "reward": "Reward", @@ -2761,7 +2759,6 @@ "earningsPerYear": "%{amount} %{symbol} /yr", "recommendedForYou": "Recommended for you", "earn": "Earn", - "myBalance": "My Balance", "providers": "Providers", "successStaked": "You successfully staked %{amount} %{symbol}", "successUnstaked": "You successfully unstaked %{amount} %{symbol}", @@ -2798,7 +2795,10 @@ "underMaintenanceDescription": "This yield opportunity is currently under maintenance. Deposits may be unavailable.", "deprecated": "Deprecated", "deprecatedDescription": "This yield opportunity has been deprecated and may be discontinued soon.", - "learnMore": "Learn more", + "depositsDisabled": "Deposits Disabled", + "depositsDisabledDescription": "Deposits are temporarily unavailable for this yield opportunity.", + "withdrawalsDisabled": "Withdrawals Disabled", + "withdrawalsDisabledDescription": "Withdrawals are temporarily unavailable for this yield opportunity.", "noAvailableYields": "No yield opportunities available for your assets", "connectWalletAvailable": "Connect a wallet to see yields available for your assets", "aboutProvider": "About %{provider}", @@ -2813,12 +2813,17 @@ "kamino": "Kamino is a Solana DeFi suite unifying lending, liquidity, and leverage into one platform. Runs an Immunefi program with up to $1.5M maximum bounty.", "fluid": "Fluid is a liquidity layer built by the Instadapp team, connecting lending, DEX, borrowing, and stablecoin markets into one efficient system. Multiply audited by top-tier security firms, live since 2024, with $0.5M in bug bounty incentives.", "venus": "Venus is a lending and borrowing protocol focused on BNB Chain. Emphasizes security through third-party audits and an ongoing bug bounty program.", - "gearbox": "Gearbox is a composable leverage protocol enabling credit accounts that plug into DeFi strategies. Multiply audited by top-tier security firms, live since 2021, with $0.2M in bug bounty incentives." + "gearbox": "Gearbox is a composable leverage protocol enabling credit accounts that plug into DeFi strategies. Multiply audited by top-tier security firms, live since 2021, with $0.2M in bug bounty incentives.", + "yearn": "Yearn is a decentralized suite of products helping individuals, DAOs, and other protocols earn yield on their digital assets. Multiply audited by top-tier security firms, live since 2020, with $0.2M in bug bounty incentives.", + "spark": "Spark is a decentralized lending protocol powered by the Sky (formerly MakerDAO) ecosystem, allowing users to borrow DAI and other stablecoins at competitive rates. Runs an Immunefi program with up to $5M maximum bounty.", + "rocket-pool": "Rocket Pool is a decentralized Ethereum liquid staking protocol that issues rETH. Multiply audited by top-tier security firms, live since 2021, with $0.5M in bug bounty incentives.", + "drift": "Drift is a decentralized perpetuals exchange and lending platform built on Solana. Runs an Immunefi program with up to $0.5M maximum bounty." }, "otherYields": "Other %{symbol} Yields", "availableToDeposit": "Available to Deposit", "availableToDepositTooltip": "This is the amount of %{symbol} in your wallet that you can deposit into this yield opportunity.", "getAsset": "Get %{symbol}", + "manage": "Manage", "potentialEarningsAmount": "%{amount}/yr at %{apy}% APY", "depositNow": "Deposit Now", "strategyInfo": "Strategy Info", diff --git a/src/components/EarnDashboard/components/PositionDetails/StakingPositionsByProvider.tsx b/src/components/EarnDashboard/components/PositionDetails/StakingPositionsByProvider.tsx index 2845224ce49..f7550a58c4a 100644 --- a/src/components/EarnDashboard/components/PositionDetails/StakingPositionsByProvider.tsx +++ b/src/components/EarnDashboard/components/PositionDetails/StakingPositionsByProvider.tsx @@ -10,6 +10,7 @@ import { useLocation } from 'react-router-dom' import type { Column, Row } from 'react-table' import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' import { LazyLoadAvatar } from '@/components/LazyLoadAvatar' import { ReactTable } from '@/components/ReactTable/ReactTable' import type { YieldOpportunityDisplay } from '@/components/StakingVaults/hooks/useYieldAsOpportunities' @@ -123,8 +124,8 @@ export const StakingPositionsByProvider: React.FC ({ id: y.yieldId, - assetId: y.yieldId, - underlyingAssetId: y.yieldId, + assetId: y.inputAssetId, + underlyingAssetId: y.inputAssetId, provider: y.providerName, apy: y.apy, fiatAmount: y.fiatAmount, @@ -144,23 +145,10 @@ export const StakingPositionsByProvider: React.FC(() => { - const result = [...yieldPositionsAsUnified, ...legacyFiltered] - console.debug( - '[StakingPositionsByProvider] filteredDown:', - JSON.stringify( - result.map(r => ({ - id: r.id, - provider: r.provider, - isYield: (r as UnifiedPosition).isYield, - fiatAmount: r.fiatAmount, - })), - null, - 2, - ), - ) - return result - }, [yieldPositionsAsUnified, legacyFiltered]) + const filteredDown = useMemo( + () => [...yieldPositionsAsUnified, ...legacyFiltered], + [yieldPositionsAsUnified, legacyFiltered], + ) const handleClick = useCallback( (row: RowProps, action: DefiAction) => { @@ -276,14 +264,19 @@ export const StakingPositionsByProvider: React.FC + ) : ( + + ) return ( - + {iconElement} {providerName} diff --git a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx index e82fb192ffa..74fc903b872 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnConfirm.tsx @@ -8,8 +8,11 @@ import { EarnRoutePaths } from './types' import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' -import { getTransactionButtonText, getYieldActionLabelKeys } from '@/lib/yieldxyz/utils' +import { + getDefaultValidatorForYield, + getTransactionButtonText, + getYieldActionLabelKeys, +} from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { TransactionStepsList } from '@/pages/Yields/components/TransactionStepsList' import { YieldAssetFlow } from '@/pages/Yields/components/YieldAssetFlow' @@ -73,16 +76,15 @@ export const EarnConfirm = memo(() => { const { data: providers } = useYieldProviders() const selectedValidatorAddress = useMemo(() => { - if (!requiresValidatorSelection || !validators?.length) return undefined - const chainId = selectedYield?.chainId - const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (!requiresValidatorSelection || !validators?.length || !selectedYield) return undefined + const defaultAddress = getDefaultValidatorForYield(selectedYield.id) if (defaultAddress) { const defaultValidator = validators.find(v => v.address === defaultAddress) if (defaultValidator) return defaultValidator.address } const preferred = validators.find(v => v.preferred) return preferred?.address ?? validators[0]?.address - }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + }, [requiresValidatorSelection, validators, selectedYield]) const selectedValidator = useMemo(() => { if (!selectedValidatorAddress || !validators?.length) return undefined diff --git a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx index 55699db4b77..6980ffa86f4 100644 --- a/src/components/MultiHopTrade/components/Earn/EarnInput.tsx +++ b/src/components/MultiHopTrade/components/Earn/EarnInput.tsx @@ -26,7 +26,7 @@ import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero, positiveOrZero } from '@/lib/bignumber/bignumber' import { fromBaseUnit } from '@/lib/math' import { enterYield } from '@/lib/yieldxyz/api' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' +import { getDefaultValidatorForYield, isYieldDisabled } from '@/lib/yieldxyz/utils' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { @@ -159,9 +159,8 @@ export const EarnInput = memo( ) const selectedValidator = useMemo(() => { - if (!requiresValidatorSelection || !validators?.length) return undefined - const chainId = selectedYield?.chainId - const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + if (!requiresValidatorSelection || !validators?.length || !selectedYield) return undefined + const defaultAddress = getDefaultValidatorForYield(selectedYield.id) if (defaultAddress) { return ( validators.find(v => v.address === defaultAddress) ?? @@ -170,7 +169,7 @@ export const EarnInput = memo( ) } return validators.find(v => v.preferred) ?? validators[0] - }, [requiresValidatorSelection, validators, selectedYield?.chainId]) + }, [requiresValidatorSelection, validators, selectedYield]) const selectedValidatorAddress = selectedValidator?.address @@ -205,9 +204,9 @@ export const EarnInput = memo( args.receiverAddress = userAddress } - if (fieldNames.has('validatorAddress') && yieldChainId) { + if (fieldNames.has('validatorAddress') && selectedYield) { const validatorAddress = - selectedValidatorAddress ?? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + selectedValidatorAddress ?? getDefaultValidatorForYield(selectedYield.id) if (validatorAddress) { args.validatorAddress = validatorAddress } @@ -389,7 +388,8 @@ export const EarnInput = memo( const yieldsForAsset = useMemo(() => { if (!sellAsset?.assetId || !yieldsData?.byInputAssetId) return [] - return yieldsData.byInputAssetId[sellAsset.assetId] ?? [] + const allYields = yieldsData.byInputAssetId[sellAsset.assetId] ?? [] + return allYields.filter(y => !isYieldDisabled(y)) }, [sellAsset?.assetId, yieldsData?.byInputAssetId]) const defaultYieldForAsset = useMemo(() => { @@ -401,11 +401,13 @@ export const EarnInput = memo( const userBalance = bnOrZero(sellAssetBalanceCryptoPrecision) const actionableYield = sortedByApy.find(y => { + if (!y.status.enter) return false const minDepositAmount = bnOrZero(y.mechanics?.entryLimits?.minimum) return minDepositAmount.lte(0) || userBalance.gte(minDepositAmount) }) - return actionableYield ?? sortedByApy[0] + const enabledYield = actionableYield ?? sortedByApy.find(y => y.status.enter) + return enabledYield ?? sortedByApy[0] }, [yieldsForAsset, sellAssetBalanceCryptoPrecision]) useEffect(() => { diff --git a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx index f2f9445e730..db10a658821 100644 --- a/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx +++ b/src/components/MultiHopTrade/components/Earn/components/YieldSelector.tsx @@ -22,12 +22,9 @@ import { DialogBody } from '@/components/Modal/components/DialogBody' import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' -import { - DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, - SHAPESHIFT_VALIDATOR_LOGO, - SHAPESHIFT_VALIDATOR_NAME, -} from '@/lib/yieldxyz/constants' +import { SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, ProviderDto, ValidatorDto } from '@/lib/yieldxyz/types' +import { getDefaultValidatorForYield } from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' @@ -98,11 +95,10 @@ const YieldItem = memo( return `${apy.toFixed(2)}%` }, [yieldItem.rewardRate?.total]) - const displayInfo = useMemo(() => { + const providerOrValidatorInfo = useMemo(() => { // For staking yields with validators, show the default validator if (requiresValidator && validators?.length) { - const chainId = yieldItem.chainId - const defaultAddress = chainId ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] : undefined + const defaultAddress = getDefaultValidatorForYield(yieldItem.id) const defaultValidator = defaultAddress ? validators.find(v => v.address === defaultAddress) : undefined @@ -130,11 +126,25 @@ const YieldItem = memo( textAlign='left' > - + - {displayInfo.name} + {yieldItem.metadata.name} + + + + {providerOrValidatorInfo.name} + + {apyDisplay} @@ -260,9 +270,21 @@ export const YieldSelector = memo( {selectedYield && selectedDisplayInfo ? ( - - {selectedDisplayInfo.name} - + + + {selectedYield.metadata.name} + + + + + {selectedDisplayInfo.name} + + + {selectedApyDisplay} diff --git a/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts b/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts index 60d6f0462ef..2618f0d5f6c 100644 --- a/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts +++ b/src/components/StakingVaults/hooks/useYieldAsOpportunities.ts @@ -1,10 +1,9 @@ import type { AssetId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' import { useMemo } from 'react' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' +import { getDefaultValidatorForYield, isYieldDisabled } from '@/lib/yieldxyz/utils' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' import type { AggregatedOpportunitiesByAssetIdReturn } from '@/state/slices/opportunitiesSlice/types' @@ -13,6 +12,7 @@ export type YieldOpportunityDisplay = { yieldId: string providerName: string providerIcon?: string + inputAssetId: AssetId apy: string fiatAmount: string cryptoAmount: string @@ -44,6 +44,9 @@ export const useYieldAsOpportunities = ( const inputAssetId = yieldItem.inputTokens?.[0]?.assetId if (!inputAssetId) return + const hasBalance = bnOrZero(yieldBalancesData?.aggregated[yieldItem.id]?.totalUsd).gt(0) + if (isYieldDisabled(yieldItem) && !hasBalance) return + if (!aggregatedByAssetId[inputAssetId]) { aggregatedByAssetId[inputAssetId] = { assetId: inputAssetId, @@ -63,13 +66,12 @@ export const useYieldAsOpportunities = ( } const balancesForYield = yieldBalancesData?.aggregated[yieldItem.id] - const { chainId } = fromAssetId(inputAssetId) let totalUsd: string let totalCrypto: string if (balancesForYield?.hasValidators) { - const defaultValidatorAddress = DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] + const defaultValidatorAddress = getDefaultValidatorForYield(yieldItem.id) const validatorAddresses = Object.keys(balancesForYield.byValidator) const selectedValidatorAddress = defaultValidatorAddress ?? validatorAddresses[0] const validatorBalance = selectedValidatorAddress @@ -103,6 +105,7 @@ export const useYieldAsOpportunities = ( yieldId: yieldItem.id, providerName: yieldItem.metadata.name || yieldItem.providerId, providerIcon: yieldItem.metadata.logoURI, + inputAssetId, apy: yieldItem.rewardRate.total.toString(), fiatAmount: bnOrZero(totalUsd).toFixed(2), cryptoAmount: bnOrZero(totalCrypto).toString(), diff --git a/src/lib/yieldxyz/augment.test.ts b/src/lib/yieldxyz/augment.test.ts new file mode 100644 index 00000000000..b44c63ee8de --- /dev/null +++ b/src/lib/yieldxyz/augment.test.ts @@ -0,0 +1,217 @@ +import { solanaChainId } from '@shapeshiftoss/caip' +import { describe, expect, it, vi } from 'vitest' + +import { augmentYield } from './augment' +import type { YieldDto } from './types' +import { YieldNetwork } from './types' + +vi.mock('@/context/PluginProvider/chainAdapterSingleton', () => ({ + getChainAdapterManager: () => ({ + get: (chainId: string) => { + if (chainId === solanaChainId) { + return { + getFeeAssetId: () => 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501', + } + } + return undefined + }, + }), +})) + +const createMockYieldDto = (overrides: Partial = {}): YieldDto => + ({ + id: 'test-yield-id', + network: YieldNetwork.Solana, + chainId: '', + token: { + name: 'Test Token', + symbol: 'TEST', + decimals: 6, + network: YieldNetwork.Solana, + address: 'TestTokenAddress123', + logoURI: 'https://example.com/token.png', + }, + inputTokens: [ + { + name: 'Input Token', + symbol: 'INPUT', + decimals: 6, + network: YieldNetwork.Solana, + address: 'InputTokenAddress123', + logoURI: 'https://example.com/input.png', + }, + ], + outputToken: undefined, + status: { enter: true, exit: true }, + rewardRate: { + total: 0.05, + rateType: 'APY', + components: [], + }, + metadata: { + name: 'Test Yield', + logoURI: 'https://example.com/yield.png', + description: 'A test yield', + underMaintenance: false, + deprecated: false, + }, + statistics: { + tvlUsd: '1000000', + }, + mechanics: { + type: 'lending', + requiresValidatorSelection: false, + gasFeeToken: { + name: 'Solana', + symbol: 'SOL', + decimals: 9, + network: YieldNetwork.Solana, + logoURI: 'https://example.com/sol.png', + }, + arguments: { + enter: { fields: [] }, + exit: { fields: [] }, + }, + entryLimits: { minimum: '0' }, + exitLimits: { minimum: '0' }, + }, + providerId: 'test-provider', + ...overrides, + }) as YieldDto + +describe('augmentYield', () => { + describe('inputTokens handling', () => { + it('should augment inputTokens when array is provided', () => { + const yieldDto = createMockYieldDto({ + inputTokens: [ + { + name: 'Input Token', + symbol: 'INPUT', + decimals: 6, + network: YieldNetwork.Solana, + address: 'InputTokenAddress123', + logoURI: 'https://example.com/input.png', + }, + ], + }) + + const result = augmentYield(yieldDto) + + expect(result.inputTokens).toHaveLength(1) + expect(result.inputTokens[0].symbol).toBe('INPUT') + expect(result.inputTokens[0].assetId).toBe( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:InputTokenAddress123', + ) + }) + + it('should use token as inputToken when inputTokens is null', () => { + const yieldDto = createMockYieldDto({ + inputTokens: null as unknown as YieldDto['inputTokens'], + token: { + name: 'CASH', + symbol: 'CASH', + decimals: 6, + network: YieldNetwork.Solana, + address: 'CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH', + logoURI: 'https://example.com/cash.png', + }, + }) + + const result = augmentYield(yieldDto) + + expect(result.inputTokens).toHaveLength(1) + expect(result.inputTokens[0].symbol).toBe('CASH') + expect(result.inputTokens[0].assetId).toBe( + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH', + ) + }) + + it('should use token as inputToken when inputTokens is empty array', () => { + const yieldDto = createMockYieldDto({ + inputTokens: [], + token: { + name: 'CASH', + symbol: 'CASH', + decimals: 6, + network: YieldNetwork.Solana, + address: 'CASHx9KJUStyftLFWGvEVf59SGeG9sh5FfcnZMVPCASH', + logoURI: 'https://example.com/cash.png', + }, + }) + + const result = augmentYield(yieldDto) + + expect(result.inputTokens).toHaveLength(1) + expect(result.inputTokens[0].symbol).toBe('CASH') + }) + }) + + describe('status preservation', () => { + it('should preserve status.enter=true', () => { + const yieldDto = createMockYieldDto({ + status: { enter: true, exit: true }, + }) + + const result = augmentYield(yieldDto) + + expect(result.status.enter).toBe(true) + expect(result.status.exit).toBe(true) + }) + + it('should preserve status.enter=false', () => { + const yieldDto = createMockYieldDto({ + status: { enter: false, exit: true }, + }) + + const result = augmentYield(yieldDto) + + expect(result.status.enter).toBe(false) + expect(result.status.exit).toBe(true) + }) + + it('should preserve status.exit=false', () => { + const yieldDto = createMockYieldDto({ + status: { enter: true, exit: false }, + }) + + const result = augmentYield(yieldDto) + + expect(result.status.enter).toBe(true) + expect(result.status.exit).toBe(false) + }) + }) + + describe('metadata preservation', () => { + it('should preserve underMaintenance flag', () => { + const yieldDto = createMockYieldDto({ + metadata: { + name: 'Test', + logoURI: '', + description: '', + underMaintenance: true, + deprecated: false, + }, + }) + + const result = augmentYield(yieldDto) + + expect(result.metadata.underMaintenance).toBe(true) + }) + + it('should preserve deprecated flag', () => { + const yieldDto = createMockYieldDto({ + metadata: { + name: 'Test', + logoURI: '', + description: '', + underMaintenance: false, + deprecated: true, + }, + }) + + const result = augmentYield(yieldDto) + + expect(result.metadata.deprecated).toBe(true) + }) + }) +}) diff --git a/src/lib/yieldxyz/augment.ts b/src/lib/yieldxyz/augment.ts index 99b7df42f6a..de9ea6a671f 100644 --- a/src/lib/yieldxyz/augment.ts +++ b/src/lib/yieldxyz/augment.ts @@ -129,12 +129,17 @@ export const augmentYield = (yieldDto: YieldDto): AugmentedYieldDto => { const chainId = chainIdFromYieldDto(yieldDto) const evmNetworkId = parseEvmNetworkId(yieldDto.chainId) + const augmentedToken = augmentYieldToken(yieldDto.token, chainId) + const inputTokens = yieldDto.inputTokens?.length + ? yieldDto.inputTokens.map(t => augmentYieldToken(t, chainId)) + : [augmentedToken] + return { ...yieldDto, chainId, evmNetworkId, - token: augmentYieldToken(yieldDto.token, chainId), - inputTokens: yieldDto.inputTokens.map(t => augmentYieldToken(t, chainId)), + token: augmentedToken, + inputTokens, outputToken: yieldDto.outputToken ? augmentYieldToken(yieldDto.outputToken, chainId) : undefined, diff --git a/src/lib/yieldxyz/constants.ts b/src/lib/yieldxyz/constants.ts index a7690c61975..5dd1c0ca798 100644 --- a/src/lib/yieldxyz/constants.ts +++ b/src/lib/yieldxyz/constants.ts @@ -98,7 +98,10 @@ export const FIGMENT_SOLANA_VALIDATOR_ADDRESS = 'CcaHc2L43ZWjwCHART3oZoJvHLAe9hz export const FIGMENT_VALIDATOR_NAME = 'Figment' export const FIGMENT_VALIDATOR_LOGO = 'https://assets.stakek.it/validators/figment.png' -export const DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID: Partial> = { - [cosmosChainId]: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, - [solanaChainId]: FIGMENT_SOLANA_VALIDATOR_ADDRESS, +// Default validators by yield ID - used for yields that require validator selection +export const DEFAULT_VALIDATOR_BY_YIELD_ID: Record = { + // ShapeShift DAO + [COSMOS_ATOM_NATIVE_STAKING_YIELD_ID]: SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + // Figment + [SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID]: FIGMENT_SOLANA_VALIDATOR_ADDRESS, } diff --git a/src/lib/yieldxyz/utils.test.ts b/src/lib/yieldxyz/utils.test.ts index 037b94343e6..3dac14a8313 100644 --- a/src/lib/yieldxyz/utils.test.ts +++ b/src/lib/yieldxyz/utils.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' -import { SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS } from './constants' +import { FIGMENT_SOLANA_VALIDATOR_ADDRESS, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS } from './constants' import type { AugmentedYieldDto, ValidatorDto } from './types' import { ensureValidatorApr, formatYieldTxTitle, + getDefaultValidatorForYield, getTransactionButtonText, getYieldActionLabelKeys, getYieldSuccessMessageKey, @@ -363,3 +364,23 @@ describe('getYieldSuccessMessageKey', () => { expect(getYieldSuccessMessageKey('vault', 'manage')).toBe('successClaim') }) }) + +describe('getDefaultValidatorForYield', () => { + it('should return ShapeShift DAO validator for cosmos-atom-native-staking', () => { + expect(getDefaultValidatorForYield('cosmos-atom-native-staking')).toBe( + SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, + ) + }) + + it('should return Figment validator for solana-sol-native-multivalidator-staking', () => { + expect(getDefaultValidatorForYield('solana-sol-native-multivalidator-staking')).toBe( + FIGMENT_SOLANA_VALIDATOR_ADDRESS, + ) + }) + + it('should return undefined for yields without enforced validators', () => { + expect(getDefaultValidatorForYield('ethereum-eth-lido-staking')).toBeUndefined() + expect(getDefaultValidatorForYield('solana-sol-lido-staking')).toBeUndefined() + expect(getDefaultValidatorForYield('some-random-yield')).toBeUndefined() + }) +}) diff --git a/src/lib/yieldxyz/utils.ts b/src/lib/yieldxyz/utils.ts index ec55769eae3..7b20aee6e15 100644 --- a/src/lib/yieldxyz/utils.ts +++ b/src/lib/yieldxyz/utils.ts @@ -2,6 +2,7 @@ import type { ChainId } from '@shapeshiftoss/caip' import { COSMOS_NETWORK_FALLBACK_APR, + DEFAULT_VALIDATOR_BY_YIELD_ID, isSupportedYieldNetwork, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, YIELD_NETWORK_TO_CHAIN_ID, @@ -13,6 +14,13 @@ export const yieldNetworkToChainId = (network: string): ChainId | undefined => { return YIELD_NETWORK_TO_CHAIN_ID[network] } +/** + * Get the default validator address for a yield ID. + * Returns the enforced validator for yields that require validator selection. + */ +export const getDefaultValidatorForYield = (yieldId: string): string | undefined => + DEFAULT_VALIDATOR_BY_YIELD_ID[yieldId] + type TxTitlePattern = { pattern: RegExp staking: string @@ -337,3 +345,8 @@ export const getYieldSuccessMessageKey = ( return assertNever(yieldType) } } + +export const isYieldDisabled = ( + yieldItem: Pick, +): boolean => + !yieldItem.status.enter || yieldItem.metadata.underMaintenance || yieldItem.metadata.deprecated diff --git a/src/pages/Yields/YieldAssetDetails.tsx b/src/pages/Yields/YieldAssetDetails.tsx index fe2c39fbf79..ce6f6c63a61 100644 --- a/src/pages/Yields/YieldAssetDetails.tsx +++ b/src/pages/Yields/YieldAssetDetails.tsx @@ -35,7 +35,11 @@ import { YIELD_NETWORK_TO_CHAIN_ID, } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' -import { resolveYieldInputAssetIcon } from '@/lib/yieldxyz/utils' +import { + getDefaultValidatorForYield, + isYieldDisabled, + resolveYieldInputAssetIcon, +} from '@/lib/yieldxyz/utils' import { GradientApy } from '@/pages/Yields/components/GradientApy' import { YieldFilters } from '@/pages/Yields/components/YieldFilters' import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldItem' @@ -201,12 +205,19 @@ export const YieldAssetDetails = memo(() => { const filteredYields = useMemo( () => assetYields.filter(y => { + if (isYieldDisabled(y)) { + const balances = allBalances?.[y.id] + const hasBalance = + balances && + balances.reduce((sum, b) => sum.plus(bnOrZero(b.amountUsd)), bnOrZero(0)).gt(0) + if (!hasBalance) return false + } if (selectedNetwork && y.network !== selectedNetwork) return false if (selectedProvider && y.providerId !== selectedProvider) return false if (selectedType && y.mechanics.type !== selectedType) return false return true }), - [assetYields, selectedNetwork, selectedProvider, selectedType], + [assetYields, allBalances, selectedNetwork, selectedProvider, selectedType], ) const columns = useMemo[]>( @@ -322,7 +333,7 @@ export const YieldAssetDetails = memo(() => { meta: { display: { base: 'none', md: 'table-cell' } }, }, { - header: translate('yieldXYZ.yourBalance'), + header: translate('yieldXYZ.balance'), id: 'balance', accessorFn: row => { const balances = allBalances?.[row.id] @@ -379,14 +390,11 @@ export const YieldAssetDetails = memo(() => { const handleYieldClick = useCallback( (yieldId: string) => { - const balances = allBalances?.[yieldId] - const highestAmountValidator = balances?.[0]?.highestAmountUsdValidator - const url = highestAmountValidator - ? `/yields/${yieldId}?validator=${highestAmountValidator}` - : `/yields/${yieldId}` + const validator = getDefaultValidatorForYield(yieldId) + const url = validator ? `/yields/${yieldId}?validator=${validator}` : `/yields/${yieldId}` navigate(url) }, - [allBalances, navigate], + [navigate], ) const handleRowClick = useCallback( diff --git a/src/pages/Yields/YieldDetail.tsx b/src/pages/Yields/YieldDetail.tsx index 884871fcd87..2264dd99ac7 100644 --- a/src/pages/Yields/YieldDetail.tsx +++ b/src/pages/Yields/YieldDetail.tsx @@ -11,18 +11,16 @@ import { Display } from '@/components/Display' import { useFeatureFlag } from '@/hooks/useFeatureFlag/useFeatureFlag' import { bnOrZero } from '@/lib/bignumber/bignumber' import { - COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, - DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, FIGMENT_SOLANA_VALIDATOR_ADDRESS, FIGMENT_VALIDATOR_LOGO, FIGMENT_VALIDATOR_NAME, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME, - SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, } from '@/lib/yieldxyz/constants' import { getYieldDisplayName } from '@/lib/yieldxyz/getYieldDisplayName' import { YieldBalanceType } from '@/lib/yieldxyz/types' +import { getDefaultValidatorForYield } from '@/lib/yieldxyz/utils' import { YieldAvailableToDeposit } from '@/pages/Yields/components/YieldAvailableToDeposit' import { YieldHero } from '@/pages/Yields/components/YieldHero' import { YieldInfoCard } from '@/pages/Yields/components/YieldInfoCard' @@ -100,28 +98,21 @@ export const YieldDetail = memo(() => { const isBalancesLoading = !allBalancesData && isBalancesFetching const validatorParam = searchParams.get('validator') - const defaultValidator = yieldItem?.chainId - ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] - : undefined + const defaultValidator = yieldItem?.id ? getDefaultValidatorForYield(yieldItem.id) : undefined + + const isStaking = yieldItem?.mechanics.type === 'staking' + const requiresValidatorSelection = yieldItem?.mechanics.requiresValidatorSelection ?? false + const shouldFetchValidators = isStaking && requiresValidatorSelection const selectedValidatorAddress = useMemo(() => { - if ( - yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || - yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || - (yieldId?.includes('solana') && yieldId?.includes('native')) - ) { - return defaultValidator - } + if (!requiresValidatorSelection) return undefined return validatorParam || defaultValidator - }, [yieldId, validatorParam, defaultValidator]) - - const isStaking = yieldItem?.mechanics.type === 'staking' - const shouldFetchValidators = isStaking && yieldItem?.mechanics.requiresValidatorSelection + }, [requiresValidatorSelection, validatorParam, defaultValidator]) const { data: validators } = useYieldValidators(yieldItem?.id ?? '', shouldFetchValidators) const { data: yieldProviders } = useYieldProviders() - const validatorOrProvider = useMemo(() => { - if (isStaking && selectedValidatorAddress) { + const maybeValidatorOrProvider = useMemo(() => { + if (isStaking && requiresValidatorSelection && selectedValidatorAddress) { const found = validators?.find(v => v.address === selectedValidatorAddress) if (found) return { name: found.name, logoURI: found.logoURI } if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { @@ -131,7 +122,7 @@ export const YieldDetail = memo(() => { return { name: FIGMENT_VALIDATOR_NAME, logoURI: FIGMENT_VALIDATOR_LOGO } } } - if (!isStaking && yieldItem) { + if (yieldItem) { const provider = yieldProviders?.[yieldItem.providerId] if (provider) { return { @@ -141,9 +132,23 @@ export const YieldDetail = memo(() => { documentation: provider.references?.[0] ?? provider.website, } } + // Fallback for providers not in the API (e.g., drift) + // NOTE: This shouldn't happen and is a bug upstream, currently happens w/ Drift. + // Report to Yield if you see some other provider missing in /providers in the future. + return { + name: yieldItem.providerId.charAt(0).toUpperCase() + yieldItem.providerId.slice(1), + logoURI: yieldItem.metadata.logoURI, + } } return null - }, [isStaking, selectedValidatorAddress, validators, yieldItem, yieldProviders]) + }, [ + isStaking, + requiresValidatorSelection, + selectedValidatorAddress, + validators, + yieldItem, + yieldProviders, + ]) const titleOverride = useMemo(() => { if (!yieldItem) return undefined @@ -265,7 +270,7 @@ export const YieldDetail = memo(() => { yieldItem={yieldItem} userBalanceUsd={userBalances.userCurrency} userBalanceCrypto={userBalances.crypto} - validatorOrProvider={validatorOrProvider} + validatorOrProvider={maybeValidatorOrProvider} titleOverride={titleOverride} /> @@ -280,12 +285,12 @@ export const YieldDetail = memo(() => { inputTokenMarketData={inputTokenMarketData} /> - {!isStaking && validatorOrProvider && ( + {(!isStaking || !requiresValidatorSelection) && maybeValidatorOrProvider && ( )} { - {!isStaking && validatorOrProvider && ( + {(!isStaking || !requiresValidatorSelection) && maybeValidatorOrProvider && ( )} { - if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { - return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] - } + const defaultValidator = getDefaultValidatorForYield(yieldItem.id) + if (defaultValidator) return defaultValidator return validators?.[0]?.address - }, [chainId, validators]) + }, [validators, yieldItem.id]) const { data: providers } = useYieldProviders() diff --git a/src/pages/Yields/components/YieldForm.tsx b/src/pages/Yields/components/YieldForm.tsx index 33019235efc..a1755511a65 100644 --- a/src/pages/Yields/components/YieldForm.tsx +++ b/src/pages/Yields/components/YieldForm.tsx @@ -1,4 +1,17 @@ -import { Avatar, Box, Button, Flex, HStack, Icon, Input, Skeleton, Text } from '@chakra-ui/react' +import { + Alert, + AlertDescription, + AlertIcon, + Avatar, + Box, + Button, + Flex, + HStack, + Icon, + Input, + Skeleton, + Text, +} from '@chakra-ui/react' import type { AccountId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import type { ChangeEvent } from 'react' @@ -17,7 +30,6 @@ import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatte import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' import { - DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS, SHAPESHIFT_VALIDATOR_LOGO, SHAPESHIFT_VALIDATOR_NAME, @@ -25,6 +37,7 @@ import { import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' import { YieldBalanceType } from '@/lib/yieldxyz/types' import { + getDefaultValidatorForYield, getTransactionButtonText, getYieldActionLabelKeys, getYieldMinAmountKey, @@ -191,19 +204,19 @@ export const YieldForm = memo( ) const selectedValidatorAddress = useMemo(() => { + if (!shouldFetchValidators) return undefined if (validatorAddress) return validatorAddress - if (chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId]) { - return DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[chainId] - } + const defaultValidator = getDefaultValidatorForYield(yieldItem.id) + if (defaultValidator) return defaultValidator return validators?.[0]?.address - }, [chainId, validators, validatorAddress]) + }, [shouldFetchValidators, validators, validatorAddress, yieldItem.id]) const { data: providers } = useYieldProviders() const isStaking = isStakingYieldType(yieldItem.mechanics.type) - const selectedValidatorMetadata = useMemo(() => { - if (!isStaking || !selectedValidatorAddress) return null + const maybeSelectedValidatorMetadata = useMemo(() => { + if (!shouldFetchValidators || !selectedValidatorAddress) return null const found = validators?.find(v => v.address === selectedValidatorAddress) if (found) return found if (selectedValidatorAddress === SHAPESHIFT_COSMOS_VALIDATOR_ADDRESS) { @@ -214,9 +227,9 @@ export const YieldForm = memo( } } return null - }, [isStaking, selectedValidatorAddress, validators]) + }, [shouldFetchValidators, selectedValidatorAddress, validators]) - const providerMetadata = useMemo(() => { + const maybeProviderMetadata = useMemo(() => { if (!providers) return null return providers[yieldItem.providerId] }, [providers, yieldItem.providerId]) @@ -395,25 +408,32 @@ export const YieldForm = memo( } }, [step]) - const successProviderInfo = useMemo(() => { - if (isStaking && selectedValidatorMetadata) { + const maybeSuccessProviderInfo = useMemo(() => { + if (isStaking && maybeSelectedValidatorMetadata) { return { - name: selectedValidatorMetadata.name, - logoURI: selectedValidatorMetadata.logoURI, + name: maybeSelectedValidatorMetadata.name, + logoURI: maybeSelectedValidatorMetadata.logoURI, } } - if (providerMetadata) { + if (maybeProviderMetadata) { return { - name: providerMetadata.name, - logoURI: providerMetadata.logoURI, + name: maybeProviderMetadata.name, + logoURI: maybeProviderMetadata.logoURI, } } return null - }, [isStaking, selectedValidatorMetadata, providerMetadata]) + }, [isStaking, maybeSelectedValidatorMetadata, maybeProviderMetadata]) + + const isActionDisabled = useMemo(() => { + if (action === 'enter') return !yieldItem.status.enter + if (action === 'exit') return !yieldItem.status.exit + return false + }, [action, yieldItem.status.enter, yieldItem.status.exit]) const buttonDisabled = useMemo(() => { if (!isConnected) return false if (isLoading) return true + if (isActionDisabled) return true if (isClaimAction) { return !claimAction || !claimableAmount || bnOrZero(claimableAmount).lte(0) } @@ -421,6 +441,7 @@ export const YieldForm = memo( }, [ isConnected, isLoading, + isActionDisabled, isClaimAction, claimAction, claimableAmount, @@ -549,7 +570,7 @@ export const YieldForm = memo( )} - {isStaking && selectedValidatorMetadata && ( + {isStaking && maybeSelectedValidatorMetadata && ( {translate('yieldXYZ.validator')} @@ -557,24 +578,28 @@ export const YieldForm = memo( - {selectedValidatorMetadata.name} + {maybeSelectedValidatorMetadata.name} )} - {!isStaking && providerMetadata && ( + {!isStaking && maybeProviderMetadata && ( {translate('yieldXYZ.provider')} - + - {providerMetadata.name} + {maybeProviderMetadata.name} @@ -603,8 +628,8 @@ export const YieldForm = memo( inputTokenAsset?.symbol, estimatedYearlyEarningsFiat, isStaking, - selectedValidatorMetadata, - providerMetadata, + maybeSelectedValidatorMetadata, + maybeProviderMetadata, minDeposit, isBelowMinimum, action, @@ -697,6 +722,20 @@ export const YieldForm = memo( const stepsToShow = activeStepIndex >= 0 ? transactionSteps : displaySteps + const maybeActionDisabledAlert = useMemo(() => { + if (!isActionDisabled) return null + const descriptionKey = + action === 'enter' + ? 'yieldXYZ.depositsDisabledDescription' + : 'yieldXYZ.withdrawalsDisabledDescription' + return ( + + + {translate(descriptionKey)} + + ) + }, [isActionDisabled, action, translate]) + // If Success, render YieldSuccess if (isSuccess) { const successAmount = isClaimAction ? claimableAmount : cryptoAmount @@ -712,7 +751,7 @@ export const YieldForm = memo( + {maybeActionDisabledAlert} {inputContent} {!isClaimAction && percentButtons} {!isClaimAction && inputTokenAssetId && accountId && ( diff --git a/src/pages/Yields/components/YieldHero.tsx b/src/pages/Yields/components/YieldHero.tsx index 0c4c4d4244b..2b488e12fc9 100644 --- a/src/pages/Yields/components/YieldHero.tsx +++ b/src/pages/Yields/components/YieldHero.tsx @@ -84,7 +84,7 @@ export const YieldHero = memo( const yieldTitle = titleOverride ?? yieldItem.metadata.name ?? yieldItem.token.symbol - const descriptionSection = useMemo(() => { + const maybeDescriptionSection = useMemo(() => { const docUrl = validatorOrProvider?.documentation ?? yieldItem.metadata.documentation const description = validatorOrProvider?.description ?? yieldItem.metadata.description if (!description && !docUrl) return null @@ -144,6 +144,17 @@ export const YieldHero = memo( )} + {!yieldItem.status.enter && + !yieldItem.metadata.deprecated && + !yieldItem.metadata.underMaintenance && ( + + + + {translate('yieldXYZ.depositsDisabledDescription')} + + + )} + {iconSource.assetId ? ( @@ -191,7 +202,7 @@ export const YieldHero = memo( {apy}% {translate('common.apy')} - {descriptionSection} + {maybeDescriptionSection} diff --git a/src/pages/Yields/components/YieldInfoCard.tsx b/src/pages/Yields/components/YieldInfoCard.tsx index 12a2f668884..e7423bbc933 100644 --- a/src/pages/Yields/components/YieldInfoCard.tsx +++ b/src/pages/Yields/components/YieldInfoCard.tsx @@ -75,6 +75,17 @@ export const YieldInfoCard = memo( )} + {!yieldItem.status.enter && + !yieldItem.metadata.deprecated && + !yieldItem.metadata.underMaintenance && ( + + + + {translate('yieldXYZ.depositsDisabledDescription')} + + + )} + {assetIcon} diff --git a/src/pages/Yields/components/YieldItem.tsx b/src/pages/Yields/components/YieldItem.tsx index 1a239a34d29..a404684e4c6 100644 --- a/src/pages/Yields/components/YieldItem.tsx +++ b/src/pages/Yields/components/YieldItem.tsx @@ -161,6 +161,7 @@ export const YieldItem = memo( const underMaintenance = isSingle ? data.yieldItem.metadata.underMaintenance : undefined const deprecated = isSingle ? data.yieldItem.metadata.deprecated : undefined + const depositsDisabled = isSingle ? !data.yieldItem.status.enter : false const statusBadge = useMemo(() => { if (!isSingle) return null @@ -182,8 +183,17 @@ export const YieldItem = memo( ) } + if (depositsDisabled) { + return ( + + + {translate('yieldXYZ.depositsDisabled')} + + + ) + } return null - }, [isSingle, deprecated, underMaintenance, translate]) + }, [isSingle, deprecated, underMaintenance, depositsDisabled, translate]) const showAvailable = isSingle && hasAvailable && !hasBalance diff --git a/src/pages/Yields/components/YieldManager.tsx b/src/pages/Yields/components/YieldManager.tsx index c6b5792c391..9c6c7a932dc 100644 --- a/src/pages/Yields/components/YieldManager.tsx +++ b/src/pages/Yields/components/YieldManager.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate, useParams, useSearchParams } from 'react-router-dom' @@ -9,13 +9,8 @@ import { DialogBody } from '@/components/Modal/components/DialogBody' import { DialogCloseButton } from '@/components/Modal/components/DialogCloseButton' import { DialogHeader } from '@/components/Modal/components/DialogHeader' import { DialogTitle } from '@/components/Modal/components/DialogTitle' -import { - COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, - DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, - SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, -} from '@/lib/yieldxyz/constants' import { YieldBalanceType } from '@/lib/yieldxyz/types' -import { getYieldActionLabelKeys } from '@/lib/yieldxyz/utils' +import { getDefaultValidatorForYield, getYieldActionLabelKeys } from '@/lib/yieldxyz/utils' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' import { useAllYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYield } from '@/react-queries/queries/yieldxyz/useYield' @@ -31,19 +26,12 @@ export const YieldManager = () => { const { data: yieldItem } = useYield(yieldId ?? '') + const requiresValidatorSelection = yieldItem?.mechanics.requiresValidatorSelection ?? false + const validatorAddress = useMemo(() => { - // For native staking with hardcoded defaults, always use the default validator (ignore URL param) - if ( - yieldId === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || - yieldId === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || - (yieldId?.includes('solana') && yieldId?.includes('native')) - ) { - return yieldItem?.chainId - ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] - : undefined - } - return validatorParam - }, [yieldId, yieldItem?.chainId, validatorParam]) + if (!requiresValidatorSelection || !yieldItem) return undefined + return validatorParam || getDefaultValidatorForYield(yieldItem.id) + }, [requiresValidatorSelection, validatorParam, yieldItem]) const { accountId, accountNumber } = useYieldAccount() const { data: allBalancesData } = useAllYieldBalances() const balances = yieldItem?.id ? allBalancesData?.normalized[yieldItem.id] : undefined @@ -66,17 +54,24 @@ export const YieldManager = () => { return translate('yieldXYZ.manage') }, [action, yieldItem, translate, inputTokenSymbol, claimableTokenSymbol]) + const handleClose = useCallback(() => { + const newParams = new URLSearchParams(searchParams) + newParams.delete('modal') + newParams.delete('action') + navigate({ search: newParams.toString() }, { replace: true }) + }, [navigate, searchParams]) + if (!yieldItem) return null return ( - navigate(-1)} isFullScreen> + {null} {title} - navigate(-1)} /> + @@ -87,8 +82,8 @@ export const YieldManager = () => { validatorAddress={validatorAddress} accountId={accountId} accountNumber={accountNumber} - onClose={() => navigate(-1)} - onDone={() => navigate(-1)} + onClose={handleClose} + onDone={handleClose} /> diff --git a/src/pages/Yields/components/YieldPositionCard.tsx b/src/pages/Yields/components/YieldPositionCard.tsx index cf33143f91a..eb40e665ee9 100644 --- a/src/pages/Yields/components/YieldPositionCard.tsx +++ b/src/pages/Yields/components/YieldPositionCard.tsx @@ -465,6 +465,12 @@ export const YieldPositionCard = memo( onClick={handleEnter} flex={1} fontWeight='bold' + isDisabled={!yieldItem.status.enter} + title={ + !yieldItem.status.enter + ? translate('yieldXYZ.depositsDisabledDescription') + : undefined + } > {enterLabel} @@ -478,6 +484,12 @@ export const YieldPositionCard = memo( onClick={handleExit} flex={1} fontWeight='bold' + isDisabled={!yieldItem.status.exit} + title={ + !yieldItem.status.exit + ? translate('yieldXYZ.withdrawalsDisabledDescription') + : undefined + } > {exitLabel} diff --git a/src/pages/Yields/components/YieldRelatedMarkets.tsx b/src/pages/Yields/components/YieldRelatedMarkets.tsx index a4fa1da5b94..fc536021662 100644 --- a/src/pages/Yields/components/YieldRelatedMarkets.tsx +++ b/src/pages/Yields/components/YieldRelatedMarkets.tsx @@ -3,6 +3,7 @@ import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' +import { isYieldDisabled } from '@/lib/yieldxyz/utils' import { YieldItem } from '@/pages/Yields/components/YieldItem' import { useYieldProviders } from '@/react-queries/queries/yieldxyz/useYieldProviders' import { useYields } from '@/react-queries/queries/yieldxyz/useYields' @@ -26,6 +27,7 @@ export const YieldRelatedMarkets = memo( return yields.all .filter(y => { if (y.id === currentYieldId) return false + if (isYieldDisabled(y)) return false const inputSymbol = y.inputTokens?.[0]?.symbol || y.token.symbol return inputSymbol === tokenSymbol }) diff --git a/src/pages/Yields/components/YieldStats.tsx b/src/pages/Yields/components/YieldStats.tsx index fe14bd30b34..9f497dcd269 100644 --- a/src/pages/Yields/components/YieldStats.tsx +++ b/src/pages/Yields/components/YieldStats.tsx @@ -5,13 +5,8 @@ import { useSearchParams } from 'react-router-dom' import { Amount } from '@/components/Amount/Amount' import { bnOrZero } from '@/lib/bignumber/bignumber' -import { - COSMOS_ATOM_NATIVE_STAKING_YIELD_ID, - DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, - SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID, -} from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto } from '@/lib/yieldxyz/types' -import { isStakingYieldType } from '@/lib/yieldxyz/utils' +import { getDefaultValidatorForYield, isStakingYieldType } from '@/lib/yieldxyz/utils' import type { NormalizedYieldBalances } from '@/react-queries/queries/yieldxyz/useAllYieldBalances' import { useYieldValidators } from '@/react-queries/queries/yieldxyz/useYieldValidators' import { @@ -41,21 +36,13 @@ export const YieldStats = memo(({ yieldItem, balances }: YieldStatsProps) => { const { data: validators } = useYieldValidators(yieldItem.id, shouldFetchValidators) const defaultValidator = useMemo( - () => - yieldItem.chainId && DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] - ? DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldItem.chainId] - : validators?.[0]?.address, - [yieldItem.chainId, validators], + () => getDefaultValidatorForYield(yieldItem.id) ?? validators?.[0]?.address, + [yieldItem.id, validators], ) const selectedValidatorAddress = useMemo(() => { - if ( - yieldItem.id === COSMOS_ATOM_NATIVE_STAKING_YIELD_ID || - yieldItem.id === SOLANA_SOL_NATIVE_MULTIVALIDATOR_STAKING_YIELD_ID || - (yieldItem.id.includes('solana') && yieldItem.id.includes('native')) - ) { - return defaultValidator - } + const enforced = getDefaultValidatorForYield(yieldItem.id) + if (enforced) return enforced return validatorParam || defaultValidator }, [yieldItem.id, validatorParam, defaultValidator]) diff --git a/src/pages/Yields/components/YieldViewHelpers.tsx b/src/pages/Yields/components/YieldViewHelpers.tsx index 98fbad04f78..5ebcebf2fe8 100644 --- a/src/pages/Yields/components/YieldViewHelpers.tsx +++ b/src/pages/Yields/components/YieldViewHelpers.tsx @@ -54,7 +54,7 @@ export const ListHeader = memo(({ isGroup = true }: ListHeaderProps) => { [translate, isGroup], ) const tvlText = useMemo(() => translate('yieldXYZ.tvl'), [translate]) - const balanceText = useMemo(() => translate('yieldXYZ.myBalance'), [translate]) + const balanceText = useMemo(() => translate('yieldXYZ.balance'), [translate]) const providerText = useMemo( () => (isGroup ? translate('yieldXYZ.providers') : translate('yieldXYZ.provider')), [translate, isGroup], diff --git a/src/pages/Yields/components/YieldsList.tsx b/src/pages/Yields/components/YieldsList.tsx index 16f5a947c20..93124222c02 100644 --- a/src/pages/Yields/components/YieldsList.tsx +++ b/src/pages/Yields/components/YieldsList.tsx @@ -45,7 +45,13 @@ import { YIELD_NETWORK_TO_CHAIN_ID, } from '@/lib/yieldxyz/constants' import type { AugmentedYieldDto, YieldNetwork } from '@/lib/yieldxyz/types' -import { isStakingYieldType, resolveYieldInputAssetIcon, searchYields } from '@/lib/yieldxyz/utils' +import { + getDefaultValidatorForYield, + isStakingYieldType, + isYieldDisabled, + resolveYieldInputAssetIcon, + searchYields, +} from '@/lib/yieldxyz/utils' import { YieldFilters } from '@/pages/Yields/components/YieldFilters' import { YieldItem, YieldItemSkeleton } from '@/pages/Yields/components/YieldItem' import { YieldOpportunityStats } from '@/pages/Yields/components/YieldOpportunityStats' @@ -237,20 +243,32 @@ export const YieldsList = memo(() => { [], ) + const unfilteredByInputAssetId = useMemo(() => { + if (!yields?.unfiltered) return {} + return yields.unfiltered.reduce>((acc, item) => { + const assetId = item.inputTokens?.[0]?.assetId + if (assetId) { + if (!acc[assetId]) acc[assetId] = [] + acc[assetId].push(item) + } + return acc + }, {}) + }, [yields?.unfiltered]) + const unfilteredAvailableYields = useMemo(() => { - if (!isConnected || !yields?.byInputAssetId || !userCurrencyBalances || !assetBalancesBaseUnit) - return [] + if (!isConnected || !userCurrencyBalances || !assetBalancesBaseUnit) return [] const available: AugmentedYieldDto[] = [] for (const [assetId, balanceFiat] of Object.entries(userCurrencyBalances)) { - const yieldsForAsset = yields.byInputAssetId[assetId] + const yieldsForAsset = unfilteredByInputAssetId[assetId] if (!yieldsForAsset?.length) continue const balance = bnOrZero(balanceFiat) if (balance.lte(0)) continue const eligibleYields = yieldsForAsset.filter(y => { + if (isYieldDisabled(y)) return false const minDeposit = bnOrZero(y.mechanics?.entryLimits?.minimum) if (minDeposit.gt(0)) { const asset = assets[assetId] @@ -266,7 +284,7 @@ export const YieldsList = memo(() => { } return available - }, [isConnected, yields?.byInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets]) + }, [isConnected, unfilteredByInputAssetId, userCurrencyBalances, assetBalancesBaseUnit, assets]) const filterSourceYields = useMemo( () => @@ -316,7 +334,10 @@ export const YieldsList = memo(() => { return yields.assetGroups .map(group => { - let filteredYields = group.yields + let filteredYields = group.yields.filter(y => { + if (!isYieldDisabled(y)) return true + return bnOrZero(getYieldPositionBalanceUsd(y.id)).gt(0) + }) if (selectedNetwork) filteredYields = filteredYields.filter(y => y.network === selectedNetwork) if (selectedProvider) @@ -536,14 +557,11 @@ export const YieldsList = memo(() => { const handleYieldClick = useCallback( (yieldId: string) => { - const balances = allBalances?.[yieldId] - const highestAmountValidator = balances?.[0]?.highestAmountUsdValidator - const url = highestAmountValidator - ? `/yields/${yieldId}?validator=${highestAmountValidator}` - : `/yields/${yieldId}` + const validator = getDefaultValidatorForYield(yieldId) + const url = validator ? `/yields/${yieldId}?validator=${validator}` : `/yields/${yieldId}` navigate(url) }, - [navigate, allBalances], + [navigate], ) const handleRowClick = useCallback( @@ -658,7 +676,7 @@ export const YieldsList = memo(() => { meta: { display: { base: 'none', md: 'table-cell' } }, }, { - header: translate('yieldXYZ.yourBalance'), + header: translate('yieldXYZ.balance'), id: 'balance', accessorFn: row => { const balance = getYieldPositionBalanceUsd(row.id) @@ -855,7 +873,7 @@ export const YieldsList = memo(() => { - {translate('yieldXYZ.myBalance')} + {translate('yieldXYZ.balance')} diff --git a/src/pages/Yields/hooks/useYieldTransactionFlow.ts b/src/pages/Yields/hooks/useYieldTransactionFlow.ts index 624c02092fc..5f75bce2d6c 100644 --- a/src/pages/Yields/hooks/useYieldTransactionFlow.ts +++ b/src/pages/Yields/hooks/useYieldTransactionFlow.ts @@ -15,16 +15,12 @@ import { useWallet } from '@/hooks/useWallet/useWallet' import { bnOrZero } from '@/lib/bignumber/bignumber' import { toBaseUnit } from '@/lib/math' import { enterYield, exitYield, fetchAction, manageYield } from '@/lib/yieldxyz/api' -import { - DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID, - YIELD_MAX_POLL_ATTEMPTS, - YIELD_POLL_INTERVAL_MS, -} from '@/lib/yieldxyz/constants' +import { YIELD_MAX_POLL_ATTEMPTS, YIELD_POLL_INTERVAL_MS } from '@/lib/yieldxyz/constants' import type { CosmosStakeArgs } from '@/lib/yieldxyz/executeTransaction' import { executeTransaction } from '@/lib/yieldxyz/executeTransaction' import type { ActionDto, AugmentedYieldDto, TransactionDto } from '@/lib/yieldxyz/types' import { ActionStatus as YieldActionStatus, TransactionStatus } from '@/lib/yieldxyz/types' -import { formatYieldTxTitle } from '@/lib/yieldxyz/utils' +import { formatYieldTxTitle, getDefaultValidatorForYield } from '@/lib/yieldxyz/utils' import { useYieldAccount } from '@/pages/Yields/YieldAccountContext' import { reactQueries } from '@/react-queries' import { useAllowance } from '@/react-queries/hooks/useAllowance' @@ -237,8 +233,9 @@ export const useYieldTransactionFlow = ({ args.receiverAddress = userAddress } - if (fieldNames.has('validatorAddress') && yieldChainId) { - args.validatorAddress = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[yieldChainId] + const validatorField = fields.find(f => f.name === 'validatorAddress') + if (validatorField && yieldItem) { + args.validatorAddress = validatorAddress || getDefaultValidatorForYield(yieldItem.id) } if (fieldNames.has('cosmosPubKey') && yieldChainId === cosmosChainId) { @@ -464,7 +461,7 @@ export const useYieldTransactionFlow = ({ const buildCosmosStakeArgs = useCallback((): CosmosStakeArgs | undefined => { if (yieldChainId !== cosmosChainId || !yieldItem) return undefined - const validator = validatorAddress || DEFAULT_NATIVE_VALIDATOR_BY_CHAIN_ID[cosmosChainId] + const validator = validatorAddress || getDefaultValidatorForYield(yieldItem.id) if (!validator) return undefined const inputTokenDecimals =