diff --git a/.env.example b/.env.example index 73ef91c18..298159662 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ PERSONAL_TENDERLY_API_KEY= PERSONAL_ACCOUNT_SLUG= PERSONAL_PROJECT_SLUG= PERSONAL_TENDERLY_RPC_NAME= +TENDERLY_TEST_TX_FROM_ADDRESS= VITE_ALCHEMY_KEY= VITE_INFURA_PROJECT_ID= diff --git a/scripts/tenderly-vnet-status.test.ts b/scripts/tenderly-vnet-status.test.ts index dbb2ddf2e..f0b225b36 100644 --- a/scripts/tenderly-vnet-status.test.ts +++ b/scripts/tenderly-vnet-status.test.ts @@ -10,6 +10,8 @@ import { selectMatchingTenderlyVnet } from './tenderly-vnet-status' +const TEST_TX_FROM_ADDRESS = process.env.TENDERLY_TEST_TX_FROM_ADDRESS ?? '0x2222222222222222222222222222222222222222' + describe('tenderly-vnet-status', () => { it('defaults to the webops profile', () => { expect( @@ -137,7 +139,7 @@ describe('tenderly-vnet-status', () => { createdAtAgeLabel: '8s', blockNumber: 24_735_515, txHash: '0xb00dc057e50c8926896495cb31717bfa7ab47608673789b4b7b478ec114080cb', - from: '0x5b0d3243c78fb9d4ac035fb2384ffdf7a9ef6396', + from: 'TEST_TX_FROM_ADDRESS', to: '0xc56413869c6cdf96496f2b1ef801fedbdfa7ddb0' } ], diff --git a/src/components/pages/portfolio/hooks/usePortfolioModel.ts b/src/components/pages/portfolio/hooks/usePortfolioModel.ts index fdfcd45dd..600854d5c 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioModel.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.ts @@ -12,6 +12,7 @@ import { } from '@pages/vaults/domain/kongVaultSelectors' import { getCanonicalHoldingsVaultAddress } from '@pages/vaults/domain/normalizeVault' import { isNonYearnErc4626Vault } from '@pages/vaults/domain/vaultWarnings' +import { getHeldYieldSplitterModeSummary } from '@pages/vaults/domain/yieldSplitterModes' import { type TPossibleSortBy, useSortVaults } from '@pages/vaults/hooks/useSortVaults' import { useYvUsdCharts } from '@pages/vaults/hooks/useYvUsdCharts' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' @@ -232,17 +233,20 @@ export function usePortfolioModel(): TPortfolioModel { visibleHoldingsVaults.forEach((vault) => { const key = getVaultKey(vault) + const yieldModeSummary = getHeldYieldSplitterModeSummary(vault, allVaults, getBalance) flags[key] = { hasHoldings: true, isMigratable: Boolean(getVaultMigration(vault)?.available), isRetired: Boolean(getVaultInfo(vault)?.isRetired), isHidden: Boolean(getVaultInfo(vault)?.isHidden), - isNotYearn: isYvUsdVault(vault) ? false : isNonYearnErc4626Vault({ vault: vault as TKongVault }) + isNotYearn: isYvUsdVault(vault) ? false : isNonYearnErc4626Vault({ vault: vault as TKongVault }), + yieldModeLabel: yieldModeSummary?.label, + yieldModeTooltip: yieldModeSummary?.tooltip } }) return flags - }, [visibleHoldingsVaults]) + }, [allVaults, getBalance, visibleHoldingsVaults]) const isSearchingBalances = (isActive || isUserConnecting) && (isWalletLoading || isUserConnecting || isIdentityLoading) @@ -280,12 +284,27 @@ export function usePortfolioModel(): TPortfolioModel { const holdingsRows = useMemo( () => - sortedHoldings.map((vault) => ({ - key: getVaultKey(vault), - vault, - hrefOverride: getPortfolioRowHref(vault) - })), - [sortedHoldings] + sortedHoldings.map((vault) => { + const modeSummary = getHeldYieldSplitterModeSummary(vault, allVaults, getBalance) + const baseHref = getPortfolioRowHref(vault) + + if (!modeSummary?.preferredVaultAddress) { + return { + key: getVaultKey(vault), + vault, + hrefOverride: baseHref + } + } + + return { + key: getVaultKey(vault), + vault, + hrefOverride: baseHref + ? `${baseHref}?yieldMode=${modeSummary.preferredVaultAddress}` + : `/vaults/${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}?yieldMode=${modeSummary.preferredVaultAddress}` + } + }), + [allVaults, getBalance, sortedHoldings] ) const suggestedRows = useMemo((): TSuggestedItem[] => { diff --git a/src/components/pages/vaults/[chainID]/[address].tsx b/src/components/pages/vaults/[chainID]/[address].tsx index d3ba72824..da98a02c4 100644 --- a/src/components/pages/vaults/[chainID]/[address].tsx +++ b/src/components/pages/vaults/[chainID]/[address].tsx @@ -21,12 +21,17 @@ import { Widget } from '@pages/vaults/components/widget' import { MobileDrawerSettingsButton } from '@pages/vaults/components/widget/MobileDrawerSettingsButton' import { WidgetRewards } from '@pages/vaults/components/widget/rewards' import { WalletPanel } from '@pages/vaults/components/widget/WalletPanel' +import { YieldModeSelector } from '@pages/vaults/components/widget/YieldModeSelector' import { YvUsdWidget } from '@pages/vaults/components/widget/yvUSD/YvUsdWidget' import { YvUsdHeaderBanner } from '@pages/vaults/components/yvUSD/YvUsdHeaderBanner' import { getVaultChainID, + getVaultDepositAssetAddress, + getVaultInfo, + getVaultMigration, + getVaultStaking, getVaultView, - type TKongVault, + getVaultYieldSplitter, type TKongVaultView } from '@pages/vaults/domain/kongVaultSelectors' import { @@ -35,7 +40,13 @@ import { YBOLD_STAKING_ADDRESS, YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' +import { buildSnapshotBackedVault } from '@pages/vaults/domain/snapshotBackedVault' import { isNonYearnErc4626Vault, NON_YEARN_ERC4626_WARNING_MESSAGE } from '@pages/vaults/domain/vaultWarnings' +import { getYieldSplitterFallbackSourceVaultAddress } from '@pages/vaults/domain/yieldSplitterFallback' +import { + getCanonicalSourceVaultAddressForRoute, + getSourceVaultYieldModeOptions +} from '@pages/vaults/domain/yieldSplitterModes' import { useEnsureVaultListFetch } from '@pages/vaults/hooks/useEnsureVaultListFetch' import { useVaultSnapshot } from '@pages/vaults/hooks/useVaultSnapshot' import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' @@ -56,10 +67,9 @@ import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { cl, isZeroAddress, toAddress, toNormalizedBN } from '@shared/utils' import { getVaultName } from '@shared/utils/helpers' -import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' import type { ReactElement } from 'react' import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useLocation, useNavigate, useParams } from 'react-router' +import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router' import { isAddressEqual } from 'viem' import { VaultsListChip } from '@/components/pages/vaults/components/list/VaultsListChip' import { deriveListKind } from '@/components/pages/vaults/utils/vaultListFacets' @@ -141,78 +151,6 @@ const splitFirstSentence = (message: string): { title: string; body?: string } = return body ? { title, body } : { title } } -const isSnapshotLikelyV3Vault = (snapshot: TKongVaultSnapshot): boolean => { - const apiVersion = snapshot.apiVersion ?? '' - if (apiVersion.startsWith('3') || apiVersion.startsWith('~3')) { - return true - } - - const normalizedKind = snapshot.meta?.kind?.toLowerCase() ?? '' - if (normalizedKind === 'single strategy' || normalizedKind === 'multi strategy') { - return true - } - - return (snapshot.composition?.length ?? 0) > 0 || (snapshot.strategies?.length ?? 0) > 0 -} - -const buildSnapshotBackedVault = (snapshot: TKongVaultSnapshot): TKongVault => { - const token = snapshot.meta?.token - const asset = snapshot.asset - ? { - address: snapshot.asset.address, - name: snapshot.asset.name, - symbol: snapshot.asset.symbol, - decimals: snapshot.asset.decimals - } - : token - ? { - address: token.address, - name: token.name, - symbol: token.symbol, - decimals: token.decimals - } - : null - - return { - chainId: snapshot.chainId, - address: snapshot.address, - name: snapshot.name || snapshot.meta?.name || snapshot.meta?.displayName || '', - symbol: snapshot.symbol || snapshot.meta?.displaySymbol || null, - apiVersion: snapshot.apiVersion ?? null, - decimals: snapshot.decimals ?? token?.decimals ?? asset?.decimals ?? null, - asset, - tvl: snapshot.tvl?.close ?? null, - performance: null, - fees: null, - category: snapshot.meta?.category ?? null, - type: snapshot.meta?.type ?? null, - kind: snapshot.meta?.kind ?? null, - v3: isSnapshotLikelyV3Vault(snapshot), - yearn: true, - isRetired: snapshot.meta?.isRetired ?? false, - isHidden: snapshot.meta?.isHidden ?? false, - isBoosted: snapshot.meta?.isBoosted ?? false, - isHighlighted: snapshot.meta?.isHighlighted ?? false, - inclusion: snapshot.inclusion, - migration: snapshot.meta?.migration?.available, - origin: null, - strategiesCount: snapshot.composition?.length ?? snapshot.debts?.length ?? 0, - riskLevel: snapshot.risk?.riskLevel ?? null, - staking: snapshot.staking - ? { - address: snapshot.staking.address ?? null, - available: snapshot.staking.available, - source: snapshot.staking.source ?? '', - rewards: (snapshot.staking.rewards ?? []).map((reward) => ({ - ...reward, - decimals: reward.decimals ?? 18, - isFinished: reward.isFinished ?? false - })) - } - : null - } -} - function VaultWarningAlert({ message, className }: { message: string; className: string }): ReactElement { const { title, body } = splitFirstSentence(message) @@ -246,6 +184,8 @@ function VaultWarningAlert({ message, className }: { message: string; className: ) } +const YIELD_MODE_QUERY_PARAM = 'yieldMode' + function YvUsdMobileKeyMetrics({ currentVault, apyVariant, @@ -317,6 +257,7 @@ function Index(): ReactElement | null { const params = useParams() const location = useLocation() const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const chainId = Number(params.chainID) const { getBalance, onRefresh } = useWallet() const { address } = useWeb3() @@ -331,6 +272,7 @@ function Index(): ReactElement | null { const isLockedYvUsdRoute = chainId === YVUSD_CHAIN_ID && params.address ? toAddress(params.address) === YVUSD_LOCKED_ADDRESS : false const unlockedYvUsdPath = `/vaults/${YVUSD_CHAIN_ID}/${YVUSD_UNLOCKED_ADDRESS}${location.search}${location.hash}` + const requestedYieldModeAddress = searchParams.get(YIELD_MODE_QUERY_PARAM) const vaultKey = `${params.chainID}-${params.address}` const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) const [mobileDrawerAction, setMobileDrawerAction] = useState(WidgetActionType.Deposit) @@ -459,6 +401,48 @@ function Index(): ReactElement | null { return snapshotVault }, [isYBold, snapshotVault, yBoldSnapshot]) + const canonicalYieldRouteSourceAddress = useMemo(() => { + const sourceFromList = getCanonicalSourceVaultAddressForRoute( + params.address ? toAddress(params.address) : undefined, + allVaults + ) + if (sourceFromList) { + return sourceFromList + } + + const sourceFromFallback = getYieldSplitterFallbackSourceVaultAddress(params.address, chainId) + if (sourceFromFallback) { + return sourceFromFallback + } + + const sourceFromSnapshot = mergedSnapshot?.yieldSplitter?.enabled + ? toAddress(mergedSnapshot.yieldSplitter.sourceVaultAddress) + : undefined + if (!sourceFromSnapshot || !params.address) { + return undefined + } + + return sourceFromSnapshot === toAddress(params.address) ? undefined : sourceFromSnapshot + }, [allVaults, chainId, mergedSnapshot, params.address]) + + const canonicalYieldRoutePath = useMemo(() => { + if (!canonicalYieldRouteSourceAddress || !params.chainID || !params.address) { + return undefined + } + + const nextSearchParams = new URLSearchParams(searchParams) + nextSearchParams.set(YIELD_MODE_QUERY_PARAM, toAddress(params.address)) + const nextSearch = nextSearchParams.toString() + return `/vaults/${params.chainID}/${canonicalYieldRouteSourceAddress}${nextSearch ? `?${nextSearch}` : ''}${location.hash}` + }, [canonicalYieldRouteSourceAddress, location.hash, params.address, params.chainID, searchParams]) + + useEffect(() => { + if (!canonicalYieldRoutePath) { + return + } + navigate(canonicalYieldRoutePath, { replace: true }) + }, [canonicalYieldRoutePath, navigate]) + const snapshotBackedVault = useMemo(() => { if (!mergedSnapshot) return undefined return buildSnapshotBackedVault(mergedSnapshot) @@ -499,42 +483,114 @@ function Index(): ReactElement | null { return getVaultView(vaultViewInput, mergedSnapshot) }, [isYvUsd, yvUsdVault, yvUsdUnlockedVault, yvUsdLockedVault, vaultViewInput, mergedSnapshot]) + const yieldModeOptions = useMemo( + () => (isYvUsd ? [] : getSourceVaultYieldModeOptions(currentVault, allVaults)), + [allVaults, currentVault, isYvUsd] + ) + + const selectedYieldModeId = useMemo(() => { + if (yieldModeOptions.length === 0) { + return 'native' + } + + if (requestedYieldModeAddress) { + const requestedMode = yieldModeOptions.find( + (option) => !option.isNative && option.vaultAddress === toAddress(requestedYieldModeAddress) + ) + if (requestedMode) { + return requestedMode.id + } + } + + const nativeMode = yieldModeOptions.find((option) => option.isNative) + if (nativeMode) { + const nativeBalance = getBalance({ + address: nativeMode.vaultAddress, + chainID: getVaultChainID(nativeMode.vault) + }).raw + if (nativeBalance > 0n) { + return nativeMode.id + } + } + + const heldVariant = yieldModeOptions.find((option) => { + if (option.isNative) { + return false + } + return getBalance({ address: option.vaultAddress, chainID: getVaultChainID(option.vault) }).raw > 0n + }) + + return heldVariant?.id ?? 'native' + }, [getBalance, requestedYieldModeAddress, yieldModeOptions]) + + const selectedYieldMode = useMemo( + () => yieldModeOptions.find((option) => option.id === selectedYieldModeId) ?? yieldModeOptions[0], + [selectedYieldModeId, yieldModeOptions] + ) + + const widgetVault = selectedYieldMode?.vault ?? currentVault + const actionVault = widgetVault ?? currentVault + const actionVaultStaking = actionVault ? getVaultStaking(actionVault) : undefined + const actionVaultChainID = actionVault ? getVaultChainID(actionVault) : undefined + const actionVaultView = useMemo(() => { + if (!actionVault) { + return undefined + } + if (currentVault && toAddress(actionVault.address) === toAddress(currentVault.address)) { + return currentVault + } + return getVaultView(actionVault) + }, [actionVault, currentVault]) + + const handleYieldModeChange = useCallback( + (modeId: string): void => { + const nextSearchParams = new URLSearchParams(searchParams) + if (modeId === 'native') { + nextSearchParams.delete(YIELD_MODE_QUERY_PARAM) + } else { + nextSearchParams.set(YIELD_MODE_QUERY_PARAM, modeId) + } + setSearchParams(nextSearchParams) + }, + [searchParams, setSearchParams] + ) + const shouldBootstrapYvUsdVaultList = isYvUsd && !hasVaultList && !hasTriggeredVaultListFetch const isLoadingVault = isYvUsd ? isLoadingYvUsd || shouldBootstrapYvUsdVaultList : !currentVault && (isLoadingSnapshotVault || (isLoadingVaultList && !isSnapshotNotFound)) - const stakingAddress = !isZeroAddress(currentVault?.staking?.address) - ? toAddress(currentVault?.staking?.address) + const stakingAddress = !isZeroAddress(actionVaultStaking?.address ?? undefined) + ? toAddress(actionVaultStaking?.address) : undefined - const disableDepositStaking = shouldDisableStakingForDeposit || !currentVault?.staking?.available + const disableDepositStaking = shouldDisableStakingForDeposit || !actionVaultStaking?.available const vaultUserData = useVaultUserData({ - vaultAddress: toAddress(currentVault?.address ?? '0x'), - assetAddress: toAddress(currentVault?.token?.address ?? '0x'), + vaultAddress: toAddress(actionVault?.address ?? '0x'), + assetAddress: actionVault ? getVaultDepositAssetAddress(actionVault) : toAddress('0x'), stakingAddress, - stakingSource: currentVault?.staking?.source, - chainId, + stakingSource: actionVaultStaking?.source, + chainId: actionVault ? getVaultChainID(actionVault) : chainId, account: address }) const vaultShareBalance = - !!address && currentVault?.address && Number.isInteger(currentVault?.chainID) - ? getBalance({ address: toAddress(currentVault.address), chainID: currentVault.chainID }).raw + !!address && actionVault?.address && actionVaultChainID !== undefined + ? getBalance({ address: toAddress(actionVault.address), chainID: actionVaultChainID }).raw : 0n const stakingShareBalance = - !!address && !!stakingAddress && Number.isInteger(currentVault?.chainID) && !!currentVault - ? getBalance({ address: stakingAddress, chainID: currentVault.chainID }).raw + !!address && !!stakingAddress && actionVaultChainID !== undefined && !!actionVault + ? getBalance({ address: stakingAddress, chainID: actionVaultChainID }).raw : 0n - const isMigratable = Boolean(currentVault?.migration?.available) + const isMigratable = Boolean(actionVault && getVaultMigration(actionVault).available) const canShowMigrateAction = isMigratable && vaultShareBalance > 0n - const isRetired = Boolean(currentVault?.info?.isRetired) + const isRetired = Boolean(actionVault && getVaultInfo(actionVault).isRetired) const hasUserFundsInVault = vaultShareBalance > 0n || stakingShareBalance > 0n const retiredVaultAlertMessage = useMemo(() => { - if (!isRetired || !currentVault) return null - return getRetiredVaultAlertMessage({ vault: currentVault, hasUserFundsInVault }) - }, [currentVault, hasUserFundsInVault, isRetired]) + if (!isRetired || !actionVaultView) return null + return getRetiredVaultAlertMessage({ vault: actionVaultView, hasUserFundsInVault }) + }, [actionVaultView, hasUserFundsInVault, isRetired]) const shouldShowNonYearnVaultAlert = useMemo(() => { return isNonYearnErc4626Vault({ vault: metadataVault, @@ -726,18 +782,36 @@ function Index(): ReactElement | null { } const handleRewardsClaimSuccess = useCallback(() => { - if (!currentVault) { + if (!currentVault || !actionVault) { return } + + const refreshTargets = new Map() + const addRefreshTarget = (address: string | undefined, targetChainID: number | undefined): void => { + if (!address || !Number.isInteger(targetChainID) || isZeroAddress(address)) { + return + } + const normalizedAddress = toAddress(address) + const resolvedChainID = Number(targetChainID) + refreshTargets.set(`${targetChainID}/${normalizedAddress}`, { + address: normalizedAddress, + chainID: resolvedChainID + }) + } + + ;[currentVault, actionVault].forEach((vault) => { + const targetChainID = getVaultChainID(vault) + addRefreshTarget(vault.address, targetChainID) + addRefreshTarget(getVaultDepositAssetAddress(vault), targetChainID) + addRefreshTarget(getVaultStaking(vault).address ?? undefined, targetChainID) + }) + refetchSnapshot() if (isYBold) { refetchYBoldSnapshot() } - onRefresh([ - { address: currentVault.address, chainID: currentVault.chainID }, - { address: currentVault.token.address, chainID: currentVault.chainID } - ]) - }, [currentVault, refetchSnapshot, refetchYBoldSnapshot, onRefresh, isYBold]) + onRefresh(Array.from(refreshTargets.values())) + }, [actionVault, currentVault, refetchSnapshot, refetchYBoldSnapshot, onRefresh, isYBold]) useEffect(() => { updateCollapsedWidgetHeight() @@ -783,7 +857,7 @@ function Index(): ReactElement | null { }, { key: 'strategies' as const, - shouldRender: Number(currentVault.strategies?.length || 0) > 0, + shouldRender: !currentVault.yieldSplitter && Number(currentVault.strategies?.length || 0) > 0, ref: sectionRefs.strategies, content: }, @@ -1077,7 +1151,7 @@ function Index(): ReactElement | null { } }, [isMobileDrawerOpen, mobileDrawerAction, isYvUsd]) - if (isLockedYvUsdRoute || isLoadingVault || !params.address) { + if (isLockedYvUsdRoute || canonicalYieldRoutePath || isLoadingVault || !params.address) { return (
@@ -1111,8 +1185,25 @@ function Index(): ReactElement | null { } const resolvedCurrentVault = currentVault + const resolvedWidgetVault = actionVault ?? resolvedCurrentVault + const resolvedWidgetStaking = getVaultStaking(resolvedWidgetVault) + const resolvedWidgetYieldSplitter = getVaultYieldSplitter(resolvedWidgetVault) + const resolvedWidgetRewardTokens = (resolvedWidgetStaking.rewards ?? []).flatMap((reward) => + reward.address + ? [ + { + address: reward.address, + symbol: reward.symbol, + decimals: reward.decimals, + price: reward.price, + isFinished: reward.isFinished + } + ] + : [] + ) function getMobileProductTypeLabel(): string { + if (mobileListKind === 'yieldSplitter') return 'Yield Splitter' if (mobileListKind === 'allocator' || mobileListKind === 'strategy') return 'Single Asset' if (mobileListKind === 'legacy') return 'Legacy' return 'LP Token' @@ -1130,12 +1221,30 @@ function Index(): ReactElement | null { const headerStickyTop = 'var(--header-height)' const resolvedWidgetMode = widgetActions.includes(widgetMode) ? widgetMode : widgetActions[0] + const primaryMobileAction = widgetActions[0] + const secondaryMobileAction = widgetActions[1] const shouldCollapseWidgetDetails = isCompactWidget const mobileListKind = deriveListKind(resolvedCurrentVault) const mobileProductTypeLabel = getMobileProductTypeLabel() const widgetModeLabel = getWidgetModeLabel(resolvedWidgetMode) const collapsedWidgetTitle = isWidgetWalletOpen ? 'My Info' : widgetModeLabel + function renderYieldModeSelector(className?: string): ReactElement | null { + if (isYvUsd || yieldModeOptions.length <= 1) { + return null + } + + return ( +
+ +
+ ) + } + function renderDetailCharts(chartHeightPx: number, chartHeightMdPx: number): ReactElement { if (isYvUsd) { return @@ -1169,22 +1278,25 @@ function Index(): ReactElement | null { } return ( - +
+ {renderYieldModeSelector()} + +
) } @@ -1204,22 +1316,25 @@ function Index(): ReactElement | null { } return ( - +
+ {renderYieldModeSelector('px-4 pt-4')} + +
) } @@ -1437,26 +1552,22 @@ function Index(): ReactElement | null { )}
{shouldShowWidgetRewards ? (
({ - address: r.address, - symbol: r.symbol, - decimals: r.decimals, - price: r.price, - isFinished: r.isFinished - }))} - chainId={chainId} + vaultAddress={toAddress(resolvedWidgetVault.address)} + stakingAddress={resolvedWidgetStaking.address ?? undefined} + stakingSource={resolvedWidgetStaking.source} + rewardTokens={resolvedWidgetRewardTokens} + yieldSplitter={resolvedWidgetYieldSplitter} + chainId={getVaultChainID(resolvedWidgetVault)} isPanelOpen={isWidgetRewardsOpen} onOpenRewards={openWidgetRewards} onCloseRewards={closeWidgetRewards} @@ -1546,20 +1657,22 @@ function Index(): ReactElement | null {
- + {secondaryMobileAction ? ( + + ) : null}
)} diff --git a/src/components/pages/vaults/components/SuggestedVaultCard.tsx b/src/components/pages/vaults/components/SuggestedVaultCard.tsx index 49ab3cc70..6fc3e6cd7 100644 --- a/src/components/pages/vaults/components/SuggestedVaultCard.tsx +++ b/src/components/pages/vaults/components/SuggestedVaultCard.tsx @@ -46,7 +46,14 @@ export function SuggestedVaultCard({ const isAllocatorVault = listKind === 'allocator' || listKind === 'strategy' const isLegacyVault = listKind === 'legacy' - const productTypeLabel = isAllocatorVault ? 'Single Asset' : isLegacyVault ? 'Legacy' : 'LP Token' + const productTypeLabel = + listKind === 'yieldSplitter' + ? 'Yield Splitter' + : isAllocatorVault + ? 'Single Asset' + : isLegacyVault + ? 'Legacy' + : 'LP Token' const chainDescription = getChainDescription(chainID) const categoryDescription = getCategoryDescription(vaultCategory) const productTypeDescription = getProductTypeDescription(listKind) diff --git a/src/components/pages/vaults/components/compare/CompareVaultCard.tsx b/src/components/pages/vaults/components/compare/CompareVaultCard.tsx index 969e95f2c..371420549 100644 --- a/src/components/pages/vaults/components/compare/CompareVaultCard.tsx +++ b/src/components/pages/vaults/components/compare/CompareVaultCard.tsx @@ -37,6 +37,7 @@ type TVaultStrategyItem = TKongVaultStrategy const listKindLabels = { allocator: 'Allocator', strategy: 'Strategy', + yieldSplitter: 'Yield Splitter', factory: 'Factory', legacy: 'Legacy' } diff --git a/src/components/pages/vaults/components/compare/VaultsCompareModal.tsx b/src/components/pages/vaults/components/compare/VaultsCompareModal.tsx index bc7c5ebb8..33e940aae 100644 --- a/src/components/pages/vaults/components/compare/VaultsCompareModal.tsx +++ b/src/components/pages/vaults/components/compare/VaultsCompareModal.tsx @@ -42,6 +42,7 @@ type TVaultStrategyItem = TKongVaultStrategy const listKindLabels = { allocator: 'Allocator', strategy: 'Strategy', + yieldSplitter: 'Yield Splitter', factory: 'Factory', legacy: 'Legacy' } diff --git a/src/components/pages/vaults/components/detail/VaultAboutSection.tsx b/src/components/pages/vaults/components/detail/VaultAboutSection.tsx index 44ef5180d..2f2a4bc0e 100644 --- a/src/components/pages/vaults/components/detail/VaultAboutSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultAboutSection.tsx @@ -7,6 +7,7 @@ import { getVaultKind, getVaultSymbol, getVaultToken, + getVaultYieldSplitter, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { deriveListKind } from '@pages/vaults/utils/vaultListFacets' @@ -104,20 +105,46 @@ export function VaultAboutSection({ const vaultKind = getVaultKind(currentVault) const vaultCategory = getVaultCategory(currentVault) const vaultSymbol = getVaultSymbol(currentVault) + const yieldSplitter = getVaultYieldSplitter(currentVault) const description = getVaultDescription(currentVault) const chainName = getNetwork(chainID).name const chainLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${chainID}/logo-32.png` const listKind = deriveListKind(currentVault) const isAllocatorVault = listKind === 'allocator' || listKind === 'strategy' const isLegacyVault = listKind === 'legacy' - const productTypeLabel = isAllocatorVault ? 'Single Asset' : isLegacyVault ? 'Legacy' : 'LP Token' - const baseKindType: 'multi' | 'single' | undefined = - vaultKind === 'Multi Strategy' ? 'multi' : vaultKind === 'Single Strategy' ? 'single' : undefined - const fallbackKindType: 'multi' | 'single' | undefined = - listKind === 'allocator' ? 'multi' : listKind === 'strategy' ? 'single' : undefined + const productTypeLabel = + listKind === 'yieldSplitter' + ? 'Yield Splitter' + : isAllocatorVault + ? 'Single Asset' + : isLegacyVault + ? 'Legacy' + : 'LP Token' + const baseKindType: 'multi' | 'single' | 'route' | undefined = + listKind === 'yieldSplitter' + ? 'route' + : vaultKind === 'Multi Strategy' + ? 'multi' + : vaultKind === 'Single Strategy' + ? 'single' + : undefined + const fallbackKindType: 'multi' | 'single' | 'route' | undefined = + listKind === 'yieldSplitter' + ? 'route' + : listKind === 'allocator' + ? 'multi' + : listKind === 'strategy' + ? 'single' + : undefined const kindType = baseKindType ?? fallbackKindType const kindLabel: string | undefined = - kindType === 'multi' ? 'Allocator' : kindType === 'single' ? 'Strategy' : vaultKind + kindType === 'multi' + ? 'Allocator' + : kindType === 'single' + ? 'Strategy' + : kindType === 'route' + ? 'Vault-to-Vault' + : vaultKind const shouldShowKind = showKindTag && Boolean(kindLabel) const vaultTypeLabel = [productTypeLabel, shouldShowKind ? kindLabel : null].filter(Boolean).join(' | ') const chainDescription = getChainDescription(chainID) @@ -134,7 +161,13 @@ export function VaultAboutSection({ const explorerBase = getNetwork(chainID).defaultBlockExplorer const explorerHref = explorerBase ? `${explorerBase}/address/${vaultAddress}` : '' - const rawDescription = description?.trim() ? description : token.description?.trim() ? token.description : '' + const rawDescription = description?.trim() + ? description + : yieldSplitter?.uiDescription?.trim() + ? yieldSplitter.uiDescription + : token.description?.trim() + ? token.description + : '' const descriptionText = rawDescription ? rawDescription.replaceAll('{{token}}', vaultSymbol) : '' return ( diff --git a/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx b/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx index e0b3e7b4b..a39813823 100755 --- a/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx +++ b/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx @@ -5,7 +5,7 @@ import { VaultTVL } from '@pages/vaults/components/table/VaultTVL' import { WidgetTabs } from '@pages/vaults/components/widget' import { YvUsdApyTooltipContent, YvUsdTvlTooltipContent } from '@pages/vaults/components/yvUSD/YvUsdBreakdown' import { YvUsdHeaderBanner } from '@pages/vaults/components/yvUSD/YvUsdHeaderBanner' -import { getVaultView, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { getVaultView, getVaultYieldSplitter, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { useHeaderCompression } from '@pages/vaults/hooks/useHeaderCompression' import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' @@ -53,7 +53,7 @@ import type { ReactElement, Ref } from 'react' import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router' -type TVaultKindType = 'multi' | 'single' | undefined +type TVaultKindType = 'multi' | 'single' | 'route' | undefined function noop(): void {} @@ -62,6 +62,10 @@ function noopWidgetModeChange(_mode: WidgetActionType): void {} function noopSelectSection(_key: string): void {} function getVaultProductTypeLabel(listKind: ReturnType): string { + if (listKind === 'yieldSplitter') { + return 'Yield Splitter' + } + if (listKind === 'allocator' || listKind === 'strategy') { return 'Single Asset' } @@ -77,6 +81,10 @@ function getVaultKindType( kind: string | null | undefined, listKind: ReturnType ): TVaultKindType { + if (listKind === 'yieldSplitter') { + return 'route' + } + if (kind === 'Multi Strategy') { return 'multi' } @@ -105,6 +113,10 @@ function getVaultKindLabel(kindType: TVaultKindType, fallbackKind: string | null return 'Strategy' } + if (kindType === 'route') { + return 'Vault-to-Vault' + } + return fallbackKind ?? undefined } @@ -158,6 +170,11 @@ function VaultHeaderIdentity({ const productTypeLabel = getVaultProductTypeLabel(listKind) const kindType = getVaultKindType(currentVault.kind, listKind) const kindLabel = getVaultKindLabel(kindType, currentVault.kind) + const yieldSplitter = getVaultYieldSplitter(currentVault) + const yieldSplitterRouteFrom = yieldSplitter?.sourceVaultSymbol || yieldSplitter?.sourceVaultName || '' + const yieldSplitterRouteTo = yieldSplitter?.wantVaultSymbol || yieldSplitter?.wantVaultName || '' + const yieldSplitterRouteLabel = + yieldSplitterRouteFrom && yieldSplitterRouteTo ? `${yieldSplitterRouteFrom} -> ${yieldSplitterRouteTo}` : '' const chainDescription = getChainDescription(currentVault.chainID) const categoryDescription = getCategoryDescription(currentVault.category) const productTypeDescription = getProductTypeDescription(listKind) @@ -259,6 +276,9 @@ function VaultHeaderIdentity({ ) : null}
+ {!isCompressed && yieldSplitterRouteLabel ? ( + {yieldSplitterRouteLabel} + ) : null} {shouldShowMetadata ? ( @@ -300,6 +320,14 @@ function VaultHeaderIdentity({ tooltipDescription={kindDescription} /> ) : null} + {yieldSplitterRouteLabel ? ( + + ) : null} {isRetired ? ( )} - + + + {yieldSplitter ? ( + + ) : null} + + {yieldSplitter ? ( + + ) : null} + + {yieldSplitter?.rewardHandlerAddress ? ( + + ) : null} + + {yieldSplitter?.tokenizedStrategyAddress ? ( + + ) : null} + + {yieldSplitter?.rewardTokenAddresses.map((rewardTokenAddress, index) => ( + + ))} {staking.available ? ( diff --git a/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx b/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx index e8ba014e3..079079015 100644 --- a/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx +++ b/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx @@ -19,6 +19,7 @@ type TVaultsAuxiliaryListProps = { title?: string vaults: TKongVaultInput[] vaultFlags: TVaultFlagsRecord + vaultHrefOverrides?: Record apyDisplayVariant?: TVaultForwardAPYVariant resolveApyDisplayVariant?: (vault: TKongVaultInput) => TVaultForwardAPYVariant compareVaultKeys?: string[] @@ -45,6 +46,7 @@ export function VaultsAuxiliaryList({ title, vaults, vaultFlags, + vaultHrefOverrides, apyDisplayVariant, resolveApyDisplayVariant, compareVaultKeys, @@ -83,6 +85,7 @@ export function VaultsAuxiliaryList({ ): TVaultProductTypePresentation { + if (listKind === 'yieldSplitter') { + return { + productType: 'v3', + label: 'Yield Splitter', + ariaLabel: 'Yield splitter vault', + isLegacy: false, + isFilterable: false + } + } + if (listKind === 'allocator' || listKind === 'strategy') { return { productType: 'v3', label: 'Single Asset', ariaLabel: 'Show single asset vaults', - isLegacy: false + isLegacy: false, + isFilterable: true } } @@ -131,7 +146,8 @@ function getVaultProductTypePresentation(listKind: ReturnType ): TVaultKindType { + if (listKind === 'yieldSplitter') { + return 'route' + } + if (kind === 'Multi Strategy') { return 'multi' } @@ -175,6 +196,10 @@ function getVaultKindLabel(kindType: TVaultKindType, fallbackKind: string | null return 'Strategy' } + if (kindType === 'route') { + return 'Vault-to-Vault' + } + return fallbackKind ?? undefined } @@ -270,6 +295,7 @@ function VaultsListRowComponent({ const apr = getVaultAPR(currentVault) const vaultKind = getVaultKind(currentVault) const vaultCategory = getVaultCategory(currentVault) + const yieldSplitter = getVaultYieldSplitter(currentVault) const href = hrefOverride ?? `/vaults/${chainID}/${toAddress(vaultAddress)}` const network = getNetwork(chainID) const chainLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${chainID}/logo-32.png` @@ -288,12 +314,13 @@ function VaultsListRowComponent({ productType, label: productTypeLabel, ariaLabel: productTypeAriaLabel, - isLegacy: isLegacyVault + isLegacy: isLegacyVault, + isFilterable: isProductTypeFilterable } = getVaultProductTypePresentation(listKind) const showProductTypeChip = showProductTypeChipOverride ?? (Boolean(activeProductType) || Boolean(onToggleVaultType)) - const isProductTypeActive = activeProductType === productType + const isProductTypeActive = isProductTypeFilterable && activeProductType === productType const shouldCollapseProductTypeChip = - !isLegacyVault && activeProductType !== 'all' && activeProductType === productType + isProductTypeFilterable && !isLegacyVault && activeProductType !== 'all' && activeProductType === productType const isChipsCompressed = Boolean(shouldCollapseChips) || isMobile const shouldCollapseProductType = isChipsCompressed || shouldCollapseProductTypeChip const showCollapsedTooltip = isChipsCompressed @@ -376,9 +403,14 @@ function VaultsListRowComponent({ const isHiddenVault = Boolean(flags?.isHidden) const kindType = getVaultKindType(vaultKind, listKind) const kindLabel = getVaultKindLabel(kindType, vaultKind) + const yieldSplitterRouteFrom = yieldSplitter?.sourceVaultSymbol || yieldSplitter?.sourceVaultName || '' + const yieldSplitterRouteTo = yieldSplitter?.wantVaultSymbol || yieldSplitter?.wantVaultName || '' + const yieldSplitterRouteLabel = + yieldSplitterRouteFrom && yieldSplitterRouteTo ? `${yieldSplitterRouteFrom} -> ${yieldSplitterRouteTo}` : '' const activeChainIds = activeChains ?? [] const activeCategoryLabels = activeCategories ?? [] - const showKindChip = showStrategies && Boolean(kindType) && (showAllocatorChip || kindType !== 'multi') + const showKindChip = + Boolean(kindType) && (kindType === 'route' || (showStrategies && (showAllocatorChip || kindType !== 'multi'))) const isKindActive = false const chainDescription = getChainDescription(chainID) const categoryDescription = getCategoryDescription(vaultCategory) @@ -648,7 +680,11 @@ function VaultsListRowComponent({ isCollapsed={shouldCollapseProductType} showCollapsedTooltip={showCollapsedTooltip} tooltipDescription={productTypeDescription} - onClick={onToggleVaultType ? (): void => onToggleVaultType(productType) : undefined} + onClick={ + isProductTypeFilterable && onToggleVaultType + ? (): void => onToggleVaultType(productType) + : undefined + } onHoverChange={handleInteractiveHoverChange} ariaLabel={productTypeAriaLabel} /> @@ -669,11 +705,31 @@ function VaultsListRowComponent({ isCollapsed={isChipsCompressed} showCollapsedTooltip={showCollapsedTooltip} tooltipDescription={kindDescription} - onClick={kindType && onToggleType ? (): void => onToggleType(kindType) : undefined} + onClick={ + kindType && kindType !== 'route' && onToggleType ? (): void => onToggleType(kindType) : undefined + } onHoverChange={handleInteractiveHoverChange} ariaLabel={`Filter by ${kindLabel}`} /> ) : null} + {yieldSplitterRouteLabel ? ( + + ) : null} + {flags?.yieldModeLabel ? ( + + ) : null} {flags?.isRetired ? ( void +} + +export function YieldModeSelector({ modes, activeModeId, onChange }: TProps): ReactElement | null { + if (modes.length <= 1) { + return null + } + + const activeMode = modes.find((mode) => mode.id === activeModeId) ?? modes[0] + + return ( +
+
+
+ {'How do you want to earn yield?'} + + {'Choose whether this vault compounds natively or routes yield into another asset.'} + +
+
+
+ {modes.map((mode) => ( + + ))} +
+ {activeMode?.description ?

{activeMode.description}

: null} +
+ ) +} diff --git a/src/components/pages/vaults/components/widget/index.tsx b/src/components/pages/vaults/components/widget/index.tsx index 31c04939e..e17e51609 100644 --- a/src/components/pages/vaults/components/widget/index.tsx +++ b/src/components/pages/vaults/components/widget/index.tsx @@ -1,10 +1,10 @@ import { getVaultAPR, + getVaultDepositAssetAddress, getVaultInfo, getVaultMigration, getVaultStaking, getVaultSymbol, - getVaultToken, getVaultVersion, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' @@ -83,7 +83,7 @@ export const Widget = forwardRef(function Widget( const [internalMode, setInternalMode] = useState(actions[0]) const currentMode = mode ?? internalMode const setMode = onModeChange ?? setInternalMode - const assetToken = getVaultToken(currentVault).address + const assetToken = getVaultDepositAssetAddress(currentVault) const vaultAPR = getVaultAPR(currentVault) const vaultSymbol = getVaultSymbol(currentVault) const vaultStaking = getVaultStaking(currentVault) diff --git a/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.tsx b/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.tsx new file mode 100644 index 000000000..c428e3fbc --- /dev/null +++ b/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.tsx @@ -0,0 +1,124 @@ +import { useClaimYieldSplitterRewards } from '@pages/vaults/hooks/rewards/useClaimYieldSplitterRewards' +import { Button } from '@shared/components/Button' +import { TokenLogo } from '@shared/components/TokenLogo' +import { useChainId } from '@shared/hooks/useAppWagmi' +import { toNormalizedValue } from '@shared/utils' +import { formatAmount, formatUSD } from '@shared/utils/format' +import type { ReactElement } from 'react' +import { useCallback, useMemo } from 'react' +import { useWriteContract } from 'wagmi' +import type { TransactionStep } from '../shared/TransactionOverlay' +import type { TStakingReward } from './types' +import { buildYieldSplitterClaimCopy } from './YieldSplitterRewardRow.utils' + +type TYieldSplitterRewardRowProps = { + rewards: TStakingReward[] + splitterAddress: `0x${string}` + chainId: number + onStartClaim: (step: TransactionStep) => void + isFirst?: boolean +} + +export function YieldSplitterRewardRow(props: TYieldSplitterRewardRowProps): ReactElement { + const { rewards, splitterAddress, chainId, onStartClaim, isFirst } = props + const currentChainId = useChainId() + const { isPending } = useWriteContract() + + const hasClaimableRewards = rewards.some((reward) => reward.amount > 0n) + const { prepare } = useClaimYieldSplitterRewards({ + splitterAddress, + chainId, + enabled: hasClaimableRewards + }) + + const normalizedRewards = useMemo( + () => + rewards.map((reward) => ({ + ...reward, + normalizedAmount: toNormalizedValue(reward.amount, reward.decimals) + })), + [rewards] + ) + + const totalUsd = useMemo( + () => normalizedRewards.reduce((total, reward) => total + reward.usdValue, 0), + [normalizedRewards] + ) + + const claimCopy = useMemo(() => buildYieldSplitterClaimCopy(rewards), [rewards]) + + const step = useMemo((): TransactionStep | undefined => { + if (!prepare.isSuccess || !prepare.data?.request) { + return undefined + } + + return { + prepare, + label: 'Claim', + confirmMessage: claimCopy.confirmMessage, + successTitle: 'Rewards Claimed', + successMessage: claimCopy.successMessage, + showConfetti: true + } + }, [claimCopy.confirmMessage, claimCopy.successMessage, prepare]) + + const handleClaim = useCallback(() => { + if (!step) { + return + } + onStartClaim(step) + }, [onStartClaim, step]) + + const isWrongChain = currentChainId !== chainId + const canClaim = prepare.isSuccess && !isWrongChain + + const getTokenLogoUrl = useCallback( + (tokenAddress: `0x${string}`) => + `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/tokens/${chainId}/${tokenAddress.toLowerCase()}/logo-128.png`, + [chainId] + ) + + return ( +
+ {!isFirst &&
} +
+
+
+ {formatUSD(totalUsd, 2, 2)} + {rewards.length > 1 ? ( + {`${rewards.length} reward tokens`} + ) : null} +
+
+ {normalizedRewards.map((reward) => ( +
+ + {formatAmount(reward.normalizedAmount.toString(), 2, 6)} + + {reward.symbol} + +
+ ))} +
+
+ +
+ +
+
+
+ ) +} diff --git a/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.utils.test.ts b/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.utils.test.ts new file mode 100644 index 000000000..c4e2320d5 --- /dev/null +++ b/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { buildYieldSplitterClaimCopy } from './YieldSplitterRewardRow.utils' + +describe('buildYieldSplitterClaimCopy', () => { + it('describes a single-token claim precisely', () => { + const copy = buildYieldSplitterClaimCopy([ + { + tokenAddress: '0x1111111111111111111111111111111111111111', + symbol: 'KAT', + decimals: 18, + amount: 15n * 10n ** 17n, + price: 1, + usdValue: 1.5 + } + ]) + + expect(copy.ctaLabel).toBe('Claim') + expect(copy.confirmMessage).toBe('Claim 1.5000 KAT') + expect(copy.successMessage).toBe('You claimed 1.5000 KAT') + }) + + it('describes multi-token claims as a shared claim-all action', () => { + const copy = buildYieldSplitterClaimCopy([ + { + tokenAddress: '0x1111111111111111111111111111111111111111', + symbol: 'KAT', + decimals: 18, + amount: 15n * 10n ** 17n, + price: 1, + usdValue: 1.5 + }, + { + tokenAddress: '0x2222222222222222222222222222222222222222', + symbol: 'USDC', + decimals: 6, + amount: 2_500_000n, + price: 1, + usdValue: 2.5 + } + ]) + + expect(copy.ctaLabel).toBe('Claim all') + expect(copy.confirmMessage).toBe('Claim all yield splitter rewards (1.5000 KAT, 2.5000 USDC)') + expect(copy.successMessage).toBe('You claimed all available yield splitter rewards: KAT, USDC.') + }) +}) diff --git a/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.utils.ts b/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.utils.ts new file mode 100644 index 000000000..4f69d7b03 --- /dev/null +++ b/src/components/pages/vaults/components/widget/rewards/YieldSplitterRewardRow.utils.ts @@ -0,0 +1,33 @@ +import { toNormalizedValue } from '@shared/utils' +import type { TStakingReward } from './types' + +type TYieldSplitterClaimCopy = { + confirmMessage: string + successMessage: string + ctaLabel: string +} + +function formatClaimEntry(reward: TStakingReward): string { + const normalizedAmount = toNormalizedValue(reward.amount, reward.decimals) + return `${normalizedAmount.toFixed(4)} ${reward.symbol}` +} + +export function buildYieldSplitterClaimCopy(rewards: TStakingReward[]): TYieldSplitterClaimCopy { + if (rewards.length === 1) { + const [reward] = rewards + return { + confirmMessage: `Claim ${formatClaimEntry(reward)}`, + successMessage: `You claimed ${formatClaimEntry(reward)}`, + ctaLabel: 'Claim' + } + } + + const rewardsSummary = rewards.map(formatClaimEntry).join(', ') + const rewardSymbols = rewards.map((reward) => reward.symbol).join(', ') + + return { + confirmMessage: `Claim all yield splitter rewards (${rewardsSummary})`, + successMessage: `You claimed all available yield splitter rewards: ${rewardSymbols}.`, + ctaLabel: 'Claim all' + } +} diff --git a/src/components/pages/vaults/components/widget/rewards/index.tsx b/src/components/pages/vaults/components/widget/rewards/index.tsx index 49cecb63c..2fa4b2117 100644 --- a/src/components/pages/vaults/components/widget/rewards/index.tsx +++ b/src/components/pages/vaults/components/widget/rewards/index.tsx @@ -1,6 +1,8 @@ import { usePlausible } from '@hooks/usePlausible' +import type { TKongVaultYieldSplitter } from '@pages/vaults/domain/kongVaultSelectors' import { useMerkleRewards } from '@pages/vaults/hooks/rewards/useMerkleRewards' import { type TRewardToken, useStakingRewards } from '@pages/vaults/hooks/rewards/useStakingRewards' +import { useYieldSplitterRewards } from '@pages/vaults/hooks/rewards/useYieldSplitterRewards' import { Button } from '@shared/components/Button' import { SwitchChainPrompt } from '@shared/components/SwitchChainPrompt' import { useWeb3 } from '@shared/contexts/useWeb3' @@ -14,11 +16,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' import { MerkleRewardRow } from './MerkleRewardRow' import { StakingRewardRow } from './StakingRewardRow' +import { YieldSplitterRewardRow } from './YieldSplitterRewardRow' type TWidgetRewardsProps = { + vaultAddress?: `0x${string}` stakingAddress?: `0x${string}` stakingSource?: string rewardTokens: TRewardToken[] + yieldSplitter?: TKongVaultYieldSplitter | null chainId: number isPanelOpen?: boolean onOpenRewards?: () => void @@ -28,9 +33,11 @@ type TWidgetRewardsProps = { export function WidgetRewards(props: TWidgetRewardsProps): ReactElement | null { const { + vaultAddress, stakingAddress, stakingSource, rewardTokens, + yieldSplitter, chainId, isPanelOpen = false, onOpenRewards, @@ -60,6 +67,18 @@ export function WidgetRewards(props: TWidgetRewardsProps): ReactElement | null { enabled: isActive && !!stakingAddress }) + const { + rewards: yieldSplitterRewards, + isLoading: isYieldSplitterLoading, + refetch: refetchYieldSplitter + } = useYieldSplitterRewards({ + splitterAddress: yieldSplitter ? vaultAddress : undefined, + rewardTokenAddresses: yieldSplitter?.rewardTokenAddresses ?? [], + userAddress, + chainId, + enabled: isActive && !!yieldSplitter && !!vaultAddress + }) + const { groupedRewards: merkleRewards, isLoading: isMerkleLoading, @@ -71,15 +90,17 @@ export function WidgetRewards(props: TWidgetRewardsProps): ReactElement | null { }) const hasStakingRewards = stakingRewards.length > 0 + const hasYieldSplitterRewards = yieldSplitterRewards.length > 0 const hasMerkleRewards = merkleRewards.length > 0 - const hasAnyRewards = hasStakingRewards || hasMerkleRewards - const isRewardsLoading = isStakingLoading || isMerkleLoading + const hasAnyRewards = hasStakingRewards || hasYieldSplitterRewards || hasMerkleRewards + const isRewardsLoading = isStakingLoading || isYieldSplitterLoading || isMerkleLoading const totalUsd = useMemo(() => { const stakingTotal = stakingRewards.reduce((acc, r) => acc + r.usdValue, 0) + const yieldSplitterTotal = yieldSplitterRewards.reduce((acc, r) => acc + r.usdValue, 0) const merkleTotal = merkleRewards.reduce((acc, r) => acc + r.totalUsdValue, 0) - return stakingTotal + merkleTotal - }, [stakingRewards, merkleRewards]) + return stakingTotal + yieldSplitterTotal + merkleTotal + }, [stakingRewards, yieldSplitterRewards, merkleRewards]) const handleOpenRewards = useCallback(() => { if (!onOpenRewards || isPanelOpen) { @@ -113,9 +134,19 @@ export function WidgetRewards(props: TWidgetRewardsProps): ReactElement | null { setActiveStep(undefined) setIsComplete(true) refetchStaking() + refetchYieldSplitter() refetchMerkle() onClaimSuccess?.() - }, [trackEvent, chainId, stakingAddress, totalUsd, refetchStaking, refetchMerkle, onClaimSuccess]) + }, [ + trackEvent, + chainId, + stakingAddress, + totalUsd, + refetchStaking, + refetchYieldSplitter, + refetchMerkle, + onClaimSuccess + ]) const handleOverlayClose = useCallback(() => { setIsOverlayOpen(false) @@ -210,6 +241,15 @@ export function WidgetRewards(props: TWidgetRewardsProps): ReactElement | null { isFirst={index === 0} /> ))} + {hasYieldSplitterRewards ? ( + + ) : null} {merkleRewards.map((groupedReward, index) => ( ))} {!isOnCorrectChain && ( diff --git a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts b/src/components/pages/vaults/domain/kongVaultSelectors.test.ts index 1e1774710..1e7fad784 100644 --- a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts +++ b/src/components/pages/vaults/domain/kongVaultSelectors.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { getVaultAPR, getVaultStaking, getVaultStrategies } from './kongVaultSelectors' +import { + getVaultAPR, + getVaultDepositAssetAddress, + getVaultStaking, + getVaultStrategies, + getVaultToken, + getVaultVersion, + getVaultYieldSplitter +} from './kongVaultSelectors' const LIST_REWARD = { address: '0x3333333333333333333333333333333333333333', @@ -167,6 +175,167 @@ describe('getVaultAPR', () => { }) }) +describe('getVaultToken', () => { + it('prefers yield splitter deposit asset metadata over the generic asset token fields', () => { + const vault = { + chainId: 1, + address: '0x1111111111111111111111111111111111111111', + name: 'Yield Splitter', + symbol: 'ysUSDC', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'Generic Asset', + symbol: 'GEN', + decimals: 18 + }, + yieldSplitter: { + enabled: true, + sourceVaultAddress: '0x3333333333333333333333333333333333333333', + sourceVaultName: 'Source Vault', + sourceVaultSymbol: 'yvSRC', + wantVaultAddress: '0x4444444444444444444444444444444444444444', + wantVaultName: 'Want Vault', + wantVaultSymbol: 'yvWANT', + depositAssetAddress: '0x5555555555555555555555555555555555555555', + depositAssetName: 'Deposit Asset', + depositAssetSymbol: 'DEP', + rewardTokenAddresses: [] + }, + tvl: 0, + performance: { + oracle: { apr: 0, apy: 0 }, + estimated: { apr: 0, apy: 0, type: 'oracle', components: {} }, + historical: { net: 0, weeklyNet: 0, monthlyNet: 0, inceptionNet: 0 } + }, + fees: { + managementFee: 0, + performanceFee: 0 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Single Strategy', + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 0, + riskLevel: 1, + staking: null + } as any + + const token = getVaultToken(vault) + + expect(token.address).toBe('0x5555555555555555555555555555555555555555') + expect(token.name).toBe('Deposit Asset') + expect(token.symbol).toBe('DEP') + expect(getVaultDepositAssetAddress(vault)).toBe('0x5555555555555555555555555555555555555555') + }) + + it('merges partial snapshot yield splitter metadata with the richer list entry', () => { + const vault = { + chainId: 1, + address: '0x1111111111111111111111111111111111111111', + name: 'Yield Splitter', + symbol: 'ysUSDC', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'Generic Asset', + symbol: 'GEN', + decimals: 18 + }, + yieldSplitter: { + enabled: true, + sourceVaultAddress: '0x3333333333333333333333333333333333333333', + sourceVaultName: 'Source Vault', + sourceVaultSymbol: 'yvSRC', + wantVaultAddress: '0x4444444444444444444444444444444444444444', + wantVaultName: 'Want Vault', + wantVaultSymbol: 'yvWANT', + depositAssetAddress: '0x5555555555555555555555555555555555555555', + depositAssetName: 'Deposit Asset', + depositAssetSymbol: 'DEP', + rewardTokenAddresses: ['0x6666666666666666666666666666666666666666'], + rewardHandlerAddress: '0x7777777777777777777777777777777777777777', + tokenizedStrategyAddress: '0x8888888888888888888888888888888888888888', + uiDescription: 'Deposit one asset and earn another.' + }, + tvl: 0, + performance: { + oracle: { apr: 0, apy: 0 }, + estimated: { apr: 0, apy: 0, type: 'oracle', components: {} }, + historical: { net: 0, weeklyNet: 0, monthlyNet: 0, inceptionNet: 0 } + }, + fees: { + managementFee: 0, + performanceFee: 0 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Single Strategy', + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 0, + riskLevel: 1, + staking: null + } as any + + const snapshot = { + yieldSplitter: { + enabled: true, + sourceVaultAddress: '0x3333333333333333333333333333333333333333', + sourceVaultName: '', + sourceVaultSymbol: '', + wantVaultAddress: '0x4444444444444444444444444444444444444444', + wantVaultName: '', + wantVaultSymbol: '', + depositAssetAddress: '0x0000000000000000000000000000000000000000', + depositAssetName: '', + depositAssetSymbol: '', + rewardTokenAddresses: [], + rewardHandlerAddress: '0x0000000000000000000000000000000000000000', + tokenizedStrategyAddress: '0x0000000000000000000000000000000000000000', + uiDescription: '' + } + } as any + + const mergedYieldSplitter = getVaultYieldSplitter(vault, snapshot) + const token = getVaultToken(vault, snapshot) + + expect(mergedYieldSplitter?.sourceVaultName).toBe('Source Vault') + expect(mergedYieldSplitter?.wantVaultSymbol).toBe('yvWANT') + expect(mergedYieldSplitter?.depositAssetAddress).toBe('0x5555555555555555555555555555555555555555') + expect(mergedYieldSplitter?.rewardTokenAddresses).toEqual(['0x6666666666666666666666666666666666666666']) + expect(mergedYieldSplitter?.rewardHandlerAddress).toBe('0x7777777777777777777777777777777777777777') + expect(mergedYieldSplitter?.tokenizedStrategyAddress).toBe('0x8888888888888888888888888888888888888888') + expect(token.address).toBe('0x5555555555555555555555555555555555555555') + expect(token.symbol).toBe('DEP') + }) +}) + +describe('getVaultVersion', () => { + it('treats yield splitters without apiVersion metadata as v3 vaults', () => { + const vault = { + chainId: 1, + address: '0x1111111111111111111111111111111111111111', + apiVersion: null, + v3: false, + yieldSplitter: { + enabled: true, + sourceVaultAddress: '0x3333333333333333333333333333333333333333', + wantVaultAddress: '0x4444444444444444444444444444444444444444' + } + } as any + + expect(getVaultVersion(vault)).toBe('3') + }) +}) + describe('getVaultStrategies', () => { const vault = { chainId: 1, diff --git a/src/components/pages/vaults/domain/kongVaultSelectors.ts b/src/components/pages/vaults/domain/kongVaultSelectors.ts index c3f236e89..c61b137f4 100644 --- a/src/components/pages/vaults/domain/kongVaultSelectors.ts +++ b/src/components/pages/vaults/domain/kongVaultSelectors.ts @@ -1,11 +1,16 @@ import { normalizeVaultCategory } from '@pages/vaults/utils/normalizeVaultCategory' import { toAddress, toBigInt, toNormalizedBN } from '@shared/utils' -import type { TKongVaultListItem, TKongVaultListItemStakingReward } from '@shared/utils/schemas/kongVaultListSchema' +import type { + TKongVaultListItem, + TKongVaultListItemStakingReward, + TKongVaultListItemYieldSplitter +} from '@shared/utils/schemas/kongVaultListSchema' import type { TKongVaultSnapshot, TKongVaultSnapshotComposition, TKongVaultSnapshotDebt, - TKongVaultSnapshotStakingReward + TKongVaultSnapshotStakingReward, + TKongVaultSnapshotYieldSplitter } from '@shared/utils/schemas/kongVaultSnapshotSchema' import { zeroAddress } from 'viem' @@ -204,6 +209,38 @@ const normalizePricePerShare = (value: string | number | null | undefined, decim return toNormalizedBN(value, decimals).normalized } +const resolveOptionalAddress = (value?: string | null): `0x${string}` | undefined => { + if (!value) { + return undefined + } + + const address = toAddress(value) + return address === zeroAddress ? undefined : address +} + +type TDepositAssetOverride = { + enabled?: boolean + depositAssetAddress?: string | null + depositAssetName?: string | null + depositAssetSymbol?: string | null +} + +const applyYieldSplitterDepositAssetToToken = ( + token: TKongVaultToken, + yieldSplitter?: TDepositAssetOverride | null +): TKongVaultToken => { + if (!yieldSplitter?.enabled) { + return token + } + + return { + ...token, + address: resolveOptionalAddress(yieldSplitter.depositAssetAddress) ?? token.address, + name: yieldSplitter.depositAssetName || token.name, + symbol: yieldSplitter.depositAssetSymbol || token.symbol + } +} + const deriveDefaultCategory = ({ name, symbol, @@ -309,6 +346,25 @@ export type TKongVaultStaking = { rewards: TKongVaultStakingReward[] | null } +export type TKongVaultYieldSplitter = { + enabled: true + sourceVaultAddress: `0x${string}` + sourceVaultName: string + sourceVaultSymbol: string + wantVaultAddress: `0x${string}` + wantVaultName: string + wantVaultSymbol: string + depositAssetAddress?: `0x${string}` + depositAssetName: string + depositAssetSymbol: string + rewardTokenAddresses: `0x${string}`[] + rewardHandlerAddress?: `0x${string}` + tokenizedStrategyAddress?: `0x${string}` + displayType: string + displayKind: string + uiDescription: string +} + export type TKongVaultMigration = { available: boolean address: `0x${string}` @@ -362,6 +418,7 @@ export type TKongVaultView = { featuringScore: number strategies: TKongVaultStrategy[] | null staking: TKongVaultStaking + yieldSplitter: TKongVaultYieldSplitter | null migration: TKongVaultMigration info: TKongVaultInfo } @@ -385,6 +442,9 @@ export const getVaultVersion = (vault: TKongVaultInput, snapshot?: TKongVaultSna if (vault.apiVersion) { return vault.apiVersion } + if (getVaultYieldSplitter(vault, snapshot)?.enabled) { + return '3' + } return vault.v3 ? '3' : '2' } @@ -470,12 +530,12 @@ export const getVaultDescription = (vault: TKongVaultInput, snapshot?: TKongVaul if (isVaultView(vault)) { return vault.description } - return snapshot?.meta?.description ?? '' + return snapshot?.meta?.description ?? getVaultYieldSplitter(vault, snapshot)?.uiDescription ?? '' } export const getVaultToken = (vault: TKongVaultInput, snapshot?: TKongVaultSnapshot): TKongVaultToken => { if (isVaultView(vault)) { - return vault.token + return applyYieldSplitterDepositAssetToToken(vault.token, vault.yieldSplitter) } const metaToken = snapshot?.meta?.token const snapshotAsset = snapshot?.asset @@ -489,13 +549,16 @@ export const getVaultToken = (vault: TKongVaultInput, snapshot?: TKongVaultSnaps snapshot?.meta?.displaySymbol || '' - return { - address: toAddress(metaToken?.address ?? snapshotAsset?.address ?? listAsset?.address ?? zeroAddress), - name: metaToken?.name ?? snapshotAsset?.name ?? listAsset?.name ?? getVaultName(vault, snapshot), - symbol, - description: metaToken?.description ?? '', - decimals: resolveDecimals(metaToken?.decimals, snapshotAsset?.decimals, listAsset?.decimals, vault.decimals) - } + return applyYieldSplitterDepositAssetToToken( + { + address: toAddress(metaToken?.address ?? snapshotAsset?.address ?? listAsset?.address ?? zeroAddress), + name: metaToken?.name ?? snapshotAsset?.name ?? listAsset?.name ?? getVaultName(vault, snapshot), + symbol, + description: metaToken?.description ?? '', + decimals: resolveDecimals(metaToken?.decimals, snapshotAsset?.decimals, listAsset?.decimals, vault.decimals) + }, + getVaultYieldSplitter(vault, snapshot) + ) } export const getVaultDecimals = (vault: TKongVaultInput, snapshot?: TKongVaultSnapshot): number => { @@ -706,6 +769,107 @@ export const getVaultStaking = (vault: TKongVaultInput, snapshot?: TKongVaultSna } } +const mapYieldSplitter = ( + yieldSplitter: TKongVaultSnapshotYieldSplitter | TKongVaultListItemYieldSplitter | undefined +): TKongVaultYieldSplitter | null => { + if (!yieldSplitter?.enabled) { + return null + } + + return { + enabled: true, + sourceVaultAddress: toAddress(yieldSplitter.sourceVaultAddress), + sourceVaultName: yieldSplitter.sourceVaultName ?? '', + sourceVaultSymbol: yieldSplitter.sourceVaultSymbol ?? '', + wantVaultAddress: toAddress(yieldSplitter.wantVaultAddress), + wantVaultName: yieldSplitter.wantVaultName ?? '', + wantVaultSymbol: yieldSplitter.wantVaultSymbol ?? '', + depositAssetAddress: resolveOptionalAddress(yieldSplitter.depositAssetAddress), + depositAssetName: yieldSplitter.depositAssetName ?? '', + depositAssetSymbol: yieldSplitter.depositAssetSymbol ?? '', + rewardTokenAddresses: (yieldSplitter.rewardTokenAddresses ?? []).map((address) => toAddress(address)), + rewardHandlerAddress: resolveOptionalAddress(yieldSplitter.rewardHandlerAddress), + tokenizedStrategyAddress: resolveOptionalAddress(yieldSplitter.tokenizedStrategyAddress), + displayType: yieldSplitter.displayType ?? 'Yield Splitter', + displayKind: yieldSplitter.displayKind ?? 'Vault-to-Vault', + uiDescription: yieldSplitter.uiDescription ?? '' + } +} + +const pickYieldSplitterAddress = ( + primary: `0x${string}` | undefined, + fallback: `0x${string}` | undefined +): `0x${string}` | undefined => primary ?? fallback + +const pickRequiredYieldSplitterAddress = (primary: `0x${string}`, fallback: `0x${string}`): `0x${string}` => + primary !== zeroAddress ? primary : fallback + +const mergeYieldSplitters = ( + primary: TKongVaultSnapshotYieldSplitter | TKongVaultListItemYieldSplitter | undefined, + fallback: TKongVaultSnapshotYieldSplitter | TKongVaultListItemYieldSplitter | undefined +): TKongVaultYieldSplitter | null => { + const primaryMapped = mapYieldSplitter(primary) + const fallbackMapped = mapYieldSplitter(fallback) + + if (!primaryMapped) { + return fallbackMapped + } + if (!fallbackMapped) { + return primaryMapped + } + + return { + enabled: true, + sourceVaultAddress: pickRequiredYieldSplitterAddress( + primaryMapped.sourceVaultAddress, + fallbackMapped.sourceVaultAddress + ), + sourceVaultName: primaryMapped.sourceVaultName || fallbackMapped.sourceVaultName, + sourceVaultSymbol: primaryMapped.sourceVaultSymbol || fallbackMapped.sourceVaultSymbol, + wantVaultAddress: pickRequiredYieldSplitterAddress(primaryMapped.wantVaultAddress, fallbackMapped.wantVaultAddress), + wantVaultName: primaryMapped.wantVaultName || fallbackMapped.wantVaultName, + wantVaultSymbol: primaryMapped.wantVaultSymbol || fallbackMapped.wantVaultSymbol, + depositAssetAddress: pickYieldSplitterAddress( + primaryMapped.depositAssetAddress, + fallbackMapped.depositAssetAddress + ), + depositAssetName: primaryMapped.depositAssetName || fallbackMapped.depositAssetName, + depositAssetSymbol: primaryMapped.depositAssetSymbol || fallbackMapped.depositAssetSymbol, + rewardTokenAddresses: + primaryMapped.rewardTokenAddresses.length > 0 + ? primaryMapped.rewardTokenAddresses + : fallbackMapped.rewardTokenAddresses, + rewardHandlerAddress: pickYieldSplitterAddress( + primaryMapped.rewardHandlerAddress, + fallbackMapped.rewardHandlerAddress + ), + tokenizedStrategyAddress: pickYieldSplitterAddress( + primaryMapped.tokenizedStrategyAddress, + fallbackMapped.tokenizedStrategyAddress + ), + displayType: primaryMapped.displayType || fallbackMapped.displayType, + displayKind: primaryMapped.displayKind || fallbackMapped.displayKind, + uiDescription: primaryMapped.uiDescription || fallbackMapped.uiDescription + } +} + +export const getVaultYieldSplitter = ( + vault: TKongVaultInput, + snapshot?: TKongVaultSnapshot +): TKongVaultYieldSplitter | null => { + if (isVaultView(vault)) { + return vault.yieldSplitter + } + + return mergeYieldSplitters(snapshot?.yieldSplitter, vault.yieldSplitter) +} + +export const isYieldSplitterVault = (vault: TKongVaultInput, snapshot?: TKongVaultSnapshot): boolean => + Boolean(getVaultYieldSplitter(vault, snapshot)?.enabled) + +export const getVaultDepositAssetAddress = (vault: TKongVaultInput, snapshot?: TKongVaultSnapshot): `0x${string}` => + getVaultYieldSplitter(vault, snapshot)?.depositAssetAddress ?? getVaultToken(vault, snapshot).address + export const getVaultMigration = (vault: TKongVaultInput, snapshot?: TKongVaultSnapshot): TKongVaultMigration => { if (isVaultView(vault)) { return vault.migration @@ -931,6 +1095,7 @@ export const getVaultView = (vault: TKongVaultInput, snapshot?: TKongVaultSnapsh featuringScore: getVaultFeaturingScore(vault), strategies: getVaultStrategies(vault, snapshot), staking: getVaultStaking(vault, snapshot), + yieldSplitter: getVaultYieldSplitter(vault, snapshot), migration: getVaultMigration(vault, snapshot), info: getVaultInfo(vault, snapshot) } diff --git a/src/components/pages/vaults/domain/normalizeVault.test.ts b/src/components/pages/vaults/domain/normalizeVault.test.ts index 80f46f6ad..760766a1e 100644 --- a/src/components/pages/vaults/domain/normalizeVault.test.ts +++ b/src/components/pages/vaults/domain/normalizeVault.test.ts @@ -1,11 +1,38 @@ -import { describe, expect, it } from 'vitest' +import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' +import { afterEach, describe, expect, it } from 'vitest' import { + filterCatalogYieldSplitterVaults, getCanonicalHoldingsVaultAddress, getHoldingsAliasVaultAddress, + primeYieldSplitterHoldingsAliases, YBOLD_STAKING_ADDRESS, YBOLD_VAULT_ADDRESS } from './normalizeVault' +function makeVault(overrides: Partial): TKongVaultListItem { + return { + address: '0x1111111111111111111111111111111111111111', + chainId: 1, + origin: 'yearn', + inclusion: undefined, + token: { + address: '0x2222222222222222222222222222222222222222', + name: 'Token', + symbol: 'TKN', + decimals: 18 + }, + staking: undefined, + metadata: { + protocols: [] + }, + ...overrides + } as TKongVaultListItem +} + +afterEach(() => { + primeYieldSplitterHoldingsAliases({}) +}) + describe('holdings alias helpers', () => { it('maps the yBOLD staking wrapper to the base vault', () => { expect(getHoldingsAliasVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) @@ -16,4 +43,40 @@ describe('holdings alias helpers', () => { expect(getHoldingsAliasVaultAddress(YBOLD_VAULT_ADDRESS)).toBeUndefined() expect(getCanonicalHoldingsVaultAddress(YBOLD_VAULT_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) }) + + it('aliases yield splitter holdings back to the source vault and removes splitter rows from the catalog', () => { + const sourceVault = makeVault({ + address: '0x3333333333333333333333333333333333333333' + }) + const splitterVault = makeVault({ + address: '0x4444444444444444444444444444444444444444', + yieldSplitter: { + enabled: true, + sourceVaultAddress: sourceVault.address, + sourceVaultName: 'Source Vault', + sourceVaultSymbol: 'yvSRC', + wantVaultAddress: '0x5555555555555555555555555555555555555555', + wantVaultName: 'Want Vault', + wantVaultSymbol: 'yvWANT', + depositAssetAddress: sourceVault.asset?.address ?? '0x0000000000000000000000000000000000000000', + depositAssetName: sourceVault.asset?.name ?? '', + depositAssetSymbol: sourceVault.asset?.symbol ?? '', + rewardTokenAddresses: [] + } as never + }) + + const vaults = { + [sourceVault.address.toLowerCase()]: sourceVault, + [splitterVault.address.toLowerCase()]: splitterVault + } + + primeYieldSplitterHoldingsAliases(vaults) + + expect(getHoldingsAliasVaultAddress(splitterVault.address)).toBe(sourceVault.address) + expect(getCanonicalHoldingsVaultAddress(splitterVault.address)).toBe(sourceVault.address) + + const filteredVaults = filterCatalogYieldSplitterVaults(vaults) + expect(filteredVaults[sourceVault.address.toLowerCase()]).toBeDefined() + expect(filteredVaults[splitterVault.address.toLowerCase()]).toBeUndefined() + }) }) diff --git a/src/components/pages/vaults/domain/normalizeVault.ts b/src/components/pages/vaults/domain/normalizeVault.ts index 6c13186ac..485a70f46 100644 --- a/src/components/pages/vaults/domain/normalizeVault.ts +++ b/src/components/pages/vaults/domain/normalizeVault.ts @@ -7,10 +7,12 @@ import { type Address, zeroAddress } from 'viem' export const YBOLD_VAULT_ADDRESS: Address = '0x9F4330700a36B29952869fac9b33f45EEdd8A3d8' export const YBOLD_STAKING_ADDRESS: Address = '0x23346B04a7f55b8760E5860AA5A77383D63491cD' -const HOLDINGS_ALIAS_BY_ADDRESS: Record = { +const STATIC_HOLDINGS_ALIAS_BY_ADDRESS: Record = { [toAddress(YBOLD_STAKING_ADDRESS)]: YBOLD_VAULT_ADDRESS } +let runtimeYieldSplitterHoldingsAliasByAddress: Record = {} + export function mergeYBoldVault(baseVault: TKongVaultListItem, stakedVault: TKongVaultListItem): TKongVaultListItem { return { ...baseVault, @@ -100,12 +102,53 @@ export function patchYBoldVaults(vaults: TDict): TDict): void { + runtimeYieldSplitterHoldingsAliasByAddress = Object.values(vaults).reduce>( + (aliases, vault) => { + const sourceVaultAddress = getYieldSplitterSourceVaultAddress(vault) + if (sourceVaultAddress) { + aliases[toAddress(vault.address)] = sourceVaultAddress + } + return aliases + }, + {} + ) +} + +export function filterCatalogYieldSplitterVaults(vaults: TDict): TDict { + return Object.entries(vaults).reduce>((filteredVaults, [address, vault]) => { + if (getYieldSplitterSourceVaultAddress(vault)) { + return filteredVaults + } + filteredVaults[address] = vault + return filteredVaults + }, {}) +} + export function getHoldingsAliasVaultAddress(address: Address | string | undefined): Address | undefined { if (!address) { return undefined } - return HOLDINGS_ALIAS_BY_ADDRESS[toAddress(address)] + const normalizedAddress = toAddress(address) + return ( + runtimeYieldSplitterHoldingsAliasByAddress[normalizedAddress] ?? STATIC_HOLDINGS_ALIAS_BY_ADDRESS[normalizedAddress] + ) } export function getCanonicalHoldingsVaultAddress(address: Address | string | undefined): Address { diff --git a/src/components/pages/vaults/domain/snapshotBackedVault.test.ts b/src/components/pages/vaults/domain/snapshotBackedVault.test.ts new file mode 100644 index 000000000..f12d8102d --- /dev/null +++ b/src/components/pages/vaults/domain/snapshotBackedVault.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { buildSnapshotBackedVault, isSnapshotLikelyV3Vault } from './snapshotBackedVault' + +describe('snapshotBackedVault', () => { + it('treats yield splitter snapshots without apiVersion metadata as v3 vaults', () => { + const snapshot = { + address: '0x1111111111111111111111111111111111111111', + chainId: 1, + name: 'Yield Splitter', + symbol: 'ysUSDC', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'USDC', + symbol: 'USDC', + decimals: 6 + }, + totalAssets: '0', + tvl: { close: 0 }, + apy: null, + fees: null, + risk: null, + performance: {}, + composition: [], + debts: [], + strategies: [], + yieldSplitter: { + enabled: true, + sourceVaultAddress: '0x3333333333333333333333333333333333333333', + wantVaultAddress: '0x4444444444444444444444444444444444444444' + } + } as any + + expect(isSnapshotLikelyV3Vault(snapshot)).toBe(true) + expect(buildSnapshotBackedVault(snapshot).v3).toBe(true) + }) +}) diff --git a/src/components/pages/vaults/domain/snapshotBackedVault.ts b/src/components/pages/vaults/domain/snapshotBackedVault.ts new file mode 100644 index 000000000..26ebbea81 --- /dev/null +++ b/src/components/pages/vaults/domain/snapshotBackedVault.ts @@ -0,0 +1,84 @@ +import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' + +export const isSnapshotLikelyV3Vault = (snapshot: TKongVaultSnapshot): boolean => { + const apiVersion = snapshot.apiVersion ?? '' + if (apiVersion.startsWith('3') || apiVersion.startsWith('~3')) { + return true + } + + if (snapshot.yieldSplitter?.enabled) { + return true + } + + const normalizedType = snapshot.meta?.type?.toLowerCase() ?? '' + if (normalizedType.includes('automated yearn vault')) { + return false + } + + const normalizedKind = snapshot.meta?.kind?.toLowerCase() ?? '' + if (normalizedKind === 'single strategy' || normalizedKind === 'multi strategy') { + return true + } + + return (snapshot.composition?.length ?? 0) > 0 || (snapshot.strategies?.length ?? 0) > 0 +} + +export const buildSnapshotBackedVault = (snapshot: TKongVaultSnapshot): TKongVault => { + const token = snapshot.meta?.token + const asset = snapshot.asset + ? { + address: snapshot.asset.address, + name: snapshot.asset.name, + symbol: snapshot.asset.symbol, + decimals: snapshot.asset.decimals + } + : token + ? { + address: token.address, + name: token.name, + symbol: token.symbol, + decimals: token.decimals + } + : null + + return { + chainId: snapshot.chainId, + address: snapshot.address, + name: snapshot.name || snapshot.meta?.name || snapshot.meta?.displayName || '', + symbol: snapshot.symbol || snapshot.meta?.displaySymbol || null, + apiVersion: snapshot.apiVersion ?? null, + decimals: snapshot.decimals ?? token?.decimals ?? asset?.decimals ?? null, + asset, + tvl: snapshot.tvl?.close ?? null, + performance: null, + fees: null, + category: snapshot.meta?.category ?? null, + type: snapshot.meta?.type ?? null, + kind: snapshot.meta?.kind ?? null, + v3: isSnapshotLikelyV3Vault(snapshot), + yearn: true, + isRetired: snapshot.meta?.isRetired ?? false, + isHidden: snapshot.meta?.isHidden ?? false, + isBoosted: snapshot.meta?.isBoosted ?? false, + isHighlighted: snapshot.meta?.isHighlighted ?? false, + inclusion: snapshot.inclusion, + migration: snapshot.meta?.migration?.available, + origin: null, + strategiesCount: snapshot.composition?.length ?? snapshot.debts?.length ?? 0, + riskLevel: snapshot.risk?.riskLevel ?? null, + staking: snapshot.staking + ? { + address: snapshot.staking.address ?? null, + available: snapshot.staking.available, + source: snapshot.staking.source ?? '', + rewards: (snapshot.staking.rewards ?? []).map((reward) => ({ + ...reward, + decimals: reward.decimals ?? 18, + isFinished: reward.isFinished ?? false + })) + } + : null, + yieldSplitter: snapshot.yieldSplitter + } +} diff --git a/src/components/pages/vaults/domain/yieldSplitterFallback.test.ts b/src/components/pages/vaults/domain/yieldSplitterFallback.test.ts new file mode 100644 index 000000000..47fedd6bd --- /dev/null +++ b/src/components/pages/vaults/domain/yieldSplitterFallback.test.ts @@ -0,0 +1,105 @@ +import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' +import { describe, expect, it } from 'vitest' +import { applyYieldSplitterFallbacks, getYieldSplitterFallbackSourceVaultAddress } from './yieldSplitterFallback' + +function makeVault(overrides: Partial): TKongVaultListItem { + return { + address: '0x1111111111111111111111111111111111111111', + chainId: 747474, + origin: 'yearn', + inclusion: undefined, + name: 'Vault', + symbol: 'yvTEST', + apiVersion: '3.0.4', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'Vault Asset', + symbol: 'AST', + decimals: 18 + }, + tvl: 0, + performance: null, + fees: null, + category: 'Volatile', + type: 'Standard', + kind: 'Single Strategy', + v3: true, + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 0, + riskLevel: 1, + token: { + address: '0x2222222222222222222222222222222222222222', + name: 'Vault Asset', + symbol: 'AST', + decimals: 18 + }, + staking: undefined, + metadata: { + protocols: [] + }, + ...overrides + } as TKongVaultListItem +} + +describe('yield splitter fallback metadata', () => { + it('enriches known Katana splitter vaults when Kong omits yieldSplitter metadata', () => { + const sourceVault = makeVault({ + address: '0xE007CA01894c863d7898045ed5A3B4Abf0b18f37', + name: 'vbETH yVault', + symbol: 'yvvbETH', + asset: { + address: '0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62', + name: 'Vault Bridge ETH', + symbol: 'vbETH', + decimals: 18 + } + }) + const wantVault = makeVault({ + address: '0x80c34BD3A3569E126e7055831036aa7b212cB159', + name: 'vbUSDC yVault', + symbol: 'yvvbUSDC', + asset: { + address: '0x42bb40bF79730451B11f6De1CbA222F17b87Afd7', + name: 'Vault Bridge USDC', + symbol: 'vbUSDC', + decimals: 6 + } + }) + const splitterVault = makeVault({ + address: '0xA03e39CDeAC8c2823A6EDC80956207294807c20d', + name: 'Yearn yvvbETH to yvvbUSDC Yield Splitter', + symbol: 'ysvbETH', + asset: { + address: '0xEE7D8BCFb72bC1880D0Cf19822eB0A2e6577aB62', + name: 'Vault Bridge ETH', + symbol: 'vbETH', + decimals: 18 + } + }) + + const enrichedVaults = applyYieldSplitterFallbacks({ + [sourceVault.address.toLowerCase()]: sourceVault, + [wantVault.address.toLowerCase()]: wantVault, + [splitterVault.address.toLowerCase()]: splitterVault + }) + + expect(enrichedVaults[splitterVault.address.toLowerCase()]?.yieldSplitter).toMatchObject({ + enabled: true, + sourceVaultAddress: sourceVault.address, + wantVaultAddress: wantVault.address, + depositAssetAddress: sourceVault.asset?.address, + wantVaultSymbol: 'USD' + }) + }) + + it('resolves direct splitter vault addresses back to their source vault addresses', () => { + expect(getYieldSplitterFallbackSourceVaultAddress('0xA03e39CDeAC8c2823A6EDC80956207294807c20d', 747474)).toBe( + '0xE007CA01894c863d7898045ed5A3B4Abf0b18f37' + ) + }) +}) diff --git a/src/components/pages/vaults/domain/yieldSplitterFallback.ts b/src/components/pages/vaults/domain/yieldSplitterFallback.ts new file mode 100644 index 000000000..dfebc23be --- /dev/null +++ b/src/components/pages/vaults/domain/yieldSplitterFallback.ts @@ -0,0 +1,129 @@ +import { KATANA_CHAIN_ID } from '@pages/vaults/constants/addresses' +import type { TDict } from '@shared/types' +import { toAddress } from '@shared/utils' +import type { TKongVaultListItem, TKongVaultListItemYieldSplitter } from '@shared/utils/schemas/kongVaultListSchema' +import { zeroAddress } from 'viem' + +type TYieldSplitterFallbackRoute = { + chainId: number + sourceVaultAddress: `0x${string}` + splitterAddress: `0x${string}` + wantVaultAddress: `0x${string}` +} + +const YIELD_SPLITTER_FALLBACK_ROUTES: TYieldSplitterFallbackRoute[] = [ + { + chainId: KATANA_CHAIN_ID, + sourceVaultAddress: '0xE007CA01894c863d7898045ed5A3B4Abf0b18f37', + splitterAddress: '0xA03e39CDeAC8c2823A6EDC80956207294807c20d', + wantVaultAddress: '0x80c34BD3A3569E126e7055831036aa7b212cB159' + }, + { + chainId: KATANA_CHAIN_ID, + sourceVaultAddress: '0xAa0362eCC584B985056E47812931270b99C91f9d', + splitterAddress: '0x2f817617A682A18851E3EaCBD945b214BE70474E', + wantVaultAddress: '0x80c34BD3A3569E126e7055831036aa7b212cB159' + }, + { + chainId: KATANA_CHAIN_ID, + sourceVaultAddress: '0x80c34BD3A3569E126e7055831036aa7b212cB159', + splitterAddress: '0xF352cdbE225B82Cc458aCa3c127F2935d7EE12CB', + wantVaultAddress: '0xE007CA01894c863d7898045ed5A3B4Abf0b18f37' + }, + { + chainId: KATANA_CHAIN_ID, + sourceVaultAddress: '0x80c34BD3A3569E126e7055831036aa7b212cB159', + splitterAddress: '0x1166da048a9B0E840A57dD9Dce5378e6c32E53C4', + wantVaultAddress: '0xAa0362eCC584B985056E47812931270b99C91f9d' + }, + { + chainId: KATANA_CHAIN_ID, + sourceVaultAddress: '0xE007CA01894c863d7898045ed5A3B4Abf0b18f37', + splitterAddress: '0x17E6ee30d939d1C0186EF98265e9a4E38A056AA1', + wantVaultAddress: '0xAa0362eCC584B985056E47812931270b99C91f9d' + }, + { + chainId: KATANA_CHAIN_ID, + sourceVaultAddress: '0xAa0362eCC584B985056E47812931270b99C91f9d', + splitterAddress: '0x518EA05c41F89e36985A94c6dF8782F6d3F45111', + wantVaultAddress: '0xE007CA01894c863d7898045ed5A3B4Abf0b18f37' + } +] + +const WANT_DISPLAY_META: Record = { + [toAddress('0x80c34BD3A3569E126e7055831036aa7b212cB159')]: 'USD', + [toAddress('0xE007CA01894c863d7898045ed5A3B4Abf0b18f37')]: 'ETH', + [toAddress('0xAa0362eCC584B985056E47812931270b99C91f9d')]: 'BTC' +} + +function getFallbackRouteBySplitterAddress( + splitterAddress: string | undefined, + chainId?: number +): TYieldSplitterFallbackRoute | undefined { + if (!splitterAddress) { + return undefined + } + + const normalizedAddress = toAddress(splitterAddress) + return YIELD_SPLITTER_FALLBACK_ROUTES.find( + (route) => + toAddress(route.splitterAddress) === normalizedAddress && (chainId === undefined || route.chainId === chainId) + ) +} + +export function getYieldSplitterFallbackSourceVaultAddress( + splitterAddress: string | undefined, + chainId?: number +): `0x${string}` | undefined { + return getFallbackRouteBySplitterAddress(splitterAddress, chainId)?.sourceVaultAddress +} + +export function getYieldSplitterFallback( + vault: TKongVaultListItem, + vaults: TDict +): TKongVaultListItemYieldSplitter | undefined { + if (vault.yieldSplitter?.enabled) { + return vault.yieldSplitter + } + + const route = getFallbackRouteBySplitterAddress(vault.address, vault.chainId) + if (!route) { + return undefined + } + + const sourceVault = vaults[toAddress(route.sourceVaultAddress)] + const wantVault = vaults[toAddress(route.wantVaultAddress)] + const depositAsset = sourceVault?.asset ?? vault.asset + const wantDisplayLabel = + WANT_DISPLAY_META[toAddress(route.wantVaultAddress)] || wantVault?.symbol || wantVault?.name || 'the target vault' + + return { + enabled: true, + sourceVaultAddress: toAddress(route.sourceVaultAddress), + sourceVaultName: sourceVault?.name ?? '', + sourceVaultSymbol: sourceVault?.symbol ?? '', + wantVaultAddress: toAddress(route.wantVaultAddress), + wantVaultName: wantVault?.name ?? '', + wantVaultSymbol: wantDisplayLabel, + depositAssetAddress: depositAsset?.address ?? zeroAddress, + depositAssetName: depositAsset?.name ?? '', + depositAssetSymbol: depositAsset?.symbol ?? '', + rewardTokenAddresses: [], + rewardHandlerAddress: zeroAddress, + tokenizedStrategyAddress: zeroAddress, + displayType: 'Yield Splitter', + displayKind: 'Vault-to-Vault', + uiDescription: + sourceVault?.name && wantDisplayLabel + ? `Deposit into ${sourceVault.name} and route yield into ${wantDisplayLabel}.` + : '' + } +} + +export function applyYieldSplitterFallbacks(vaults: TDict): TDict { + return Object.entries(vaults).reduce>((accumulator, [address, vault]) => { + const yieldSplitter = getYieldSplitterFallback(vault, vaults) + accumulator[address] = yieldSplitter ? { ...vault, yieldSplitter } : vault + return accumulator + }, {}) +} diff --git a/src/components/pages/vaults/domain/yieldSplitterModes.test.ts b/src/components/pages/vaults/domain/yieldSplitterModes.test.ts new file mode 100644 index 000000000..7eb48cfb7 --- /dev/null +++ b/src/components/pages/vaults/domain/yieldSplitterModes.test.ts @@ -0,0 +1,137 @@ +import type { TDict } from '@shared/types' +import { describe, expect, it } from 'vitest' +import { + getCanonicalSourceVaultAddressForRoute, + getHeldYieldSplitterModeSummary, + getSourceVaultYieldModeOptions +} from './yieldSplitterModes' + +const SOURCE_VAULT_ADDRESS = '0x1111111111111111111111111111111111111111' +const SPLITTER_ETH_ADDRESS = '0x2222222222222222222222222222222222222222' +const SPLITTER_BTC_ADDRESS = '0x3333333333333333333333333333333333333333' +const ASSET_ADDRESS = '0x4444444444444444444444444444444444444444' + +function makeVault(overrides: Record) { + return { + chainId: 1, + address: SOURCE_VAULT_ADDRESS, + name: 'USDC Vault', + symbol: 'yvUSDC', + apiVersion: '3.0.0', + decimals: 18, + asset: { + address: ASSET_ADDRESS, + name: 'USD Coin', + symbol: 'USDC', + decimals: 6 + }, + tvl: 0, + performance: { + oracle: { apr: 0.04, apy: 0.04 }, + estimated: { apr: 0.04, apy: 0.04, type: 'oracle', components: {} }, + historical: { net: 0.03, weeklyNet: 0.03, monthlyNet: 0.02, inceptionNet: 0.01 } + }, + fees: { + managementFee: 0, + performanceFee: 0 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Single Strategy', + v3: true, + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: false, + strategiesCount: 1, + riskLevel: 1, + staking: { + address: null, + available: false, + source: '', + rewards: [] + }, + ...overrides + } as any +} + +describe('yield splitter source vault modes', () => { + const sourceVault = makeVault({}) + const ethRouteVault = makeVault({ + address: SPLITTER_ETH_ADDRESS, + name: 'USDC Vault earning ETH', + symbol: 'ysUSDC-ETH', + yieldSplitter: { + enabled: true, + sourceVaultAddress: SOURCE_VAULT_ADDRESS, + sourceVaultName: 'USDC Vault', + sourceVaultSymbol: 'yvUSDC', + wantVaultAddress: '0x5555555555555555555555555555555555555555', + wantVaultName: 'ETH Vault', + wantVaultSymbol: 'ETH', + depositAssetAddress: ASSET_ADDRESS, + depositAssetName: 'USD Coin', + depositAssetSymbol: 'USDC', + rewardTokenAddresses: [], + uiDescription: 'Deposit USDC and earn ETH.' + } + }) + const btcRouteVault = makeVault({ + address: SPLITTER_BTC_ADDRESS, + name: 'USDC Vault earning BTC', + symbol: 'ysUSDC-BTC', + yieldSplitter: { + enabled: true, + sourceVaultAddress: SOURCE_VAULT_ADDRESS, + sourceVaultName: 'USDC Vault', + sourceVaultSymbol: 'yvUSDC', + wantVaultAddress: '0x6666666666666666666666666666666666666666', + wantVaultName: 'BTC Vault', + wantVaultSymbol: 'BTC', + depositAssetAddress: ASSET_ADDRESS, + depositAssetName: 'USD Coin', + depositAssetSymbol: 'USDC', + rewardTokenAddresses: [], + uiDescription: 'Deposit USDC and earn BTC.' + } + }) + + const allVaults = { + [SOURCE_VAULT_ADDRESS.toLowerCase()]: sourceVault, + [SPLITTER_ETH_ADDRESS.toLowerCase()]: ethRouteVault, + [SPLITTER_BTC_ADDRESS.toLowerCase()]: btcRouteVault + } as TDict + + it('builds a native mode plus one option per splitter route', () => { + const modes = getSourceVaultYieldModeOptions(sourceVault, allVaults) + + expect(modes).toHaveLength(3) + expect(modes[0]).toMatchObject({ + id: 'native', + label: 'Compound USDC', + isNative: true + }) + expect(modes.slice(1).map((mode) => mode.label)).toEqual(['Earn BTC', 'Earn ETH']) + }) + + it('summarizes held splitter positions for portfolio and list rows', () => { + const summary = getHeldYieldSplitterModeSummary(sourceVault, allVaults, ({ address }) => ({ + raw: address.toLowerCase() === SPLITTER_ETH_ADDRESS.toLowerCase() ? 1n : 0n, + normalized: address.toLowerCase() === SPLITTER_ETH_ADDRESS.toLowerCase() ? 1 : 0, + display: address.toLowerCase() === SPLITTER_ETH_ADDRESS.toLowerCase() ? '1' : '0', + decimals: 18 + })) + + expect(summary).toEqual({ + label: 'Earning ETH', + tooltip: 'This position routes yield into ETH.', + preferredVaultAddress: SPLITTER_ETH_ADDRESS + }) + }) + + it('canonicalizes direct splitter routes back to their source vault page', () => { + expect(getCanonicalSourceVaultAddressForRoute(SPLITTER_ETH_ADDRESS, allVaults)).toBe(SOURCE_VAULT_ADDRESS) + expect(getCanonicalSourceVaultAddressForRoute(SOURCE_VAULT_ADDRESS, allVaults)).toBeUndefined() + }) +}) diff --git a/src/components/pages/vaults/domain/yieldSplitterModes.ts b/src/components/pages/vaults/domain/yieldSplitterModes.ts new file mode 100644 index 000000000..457c3de8f --- /dev/null +++ b/src/components/pages/vaults/domain/yieldSplitterModes.ts @@ -0,0 +1,190 @@ +import { + getVaultAddress, + getVaultChainID, + getVaultName, + getVaultStaking, + getVaultToken, + getVaultYieldSplitter, + type TKongVault, + type TKongVaultInput +} from '@pages/vaults/domain/kongVaultSelectors' +import { getCanonicalHoldingsVaultAddress } from '@pages/vaults/domain/normalizeVault' +import type { TDict } from '@shared/types' +import type { TAddress } from '@shared/types/address' +import type { TNormalizedBN } from '@shared/types/mixed' +import { isZeroAddress, toAddress } from '@shared/utils' + +type TBalanceGetter = (params: { address: TAddress; chainID: number }) => TNormalizedBN + +export type TYieldSplitterModeOption = { + id: string + vaultAddress: `0x${string}` + label: string + shortLabel: string + description: string + isNative: boolean + vault: TKongVaultInput +} + +export type TYieldSplitterHoldingsSummary = { + label: string + tooltip: string + preferredVaultAddress?: `0x${string}` +} + +function getYieldModeSortKey(vault: TKongVaultInput): string { + const yieldSplitter = getVaultYieldSplitter(vault) + return ( + yieldSplitter?.wantVaultSymbol || + yieldSplitter?.wantVaultName || + getVaultName(vault) || + getVaultAddress(vault) + ).toLowerCase() +} + +export function getYieldSplitterSourceVaultAddress(vault: TKongVaultInput | undefined): `0x${string}` | undefined { + if (!vault) { + return undefined + } + return getVaultYieldSplitter(vault)?.sourceVaultAddress +} + +export function getYieldSplitterVariantVaults( + sourceVaultAddress: TAddress | undefined, + allVaults: TDict +): TKongVault[] { + if (!sourceVaultAddress) { + return [] + } + + const normalizedSourceVaultAddress = toAddress(sourceVaultAddress) + const sourceVault = allVaults[normalizedSourceVaultAddress] + const sourceVaultChainID = sourceVault ? getVaultChainID(sourceVault) : undefined + + return Object.values(allVaults) + .filter((vault) => { + const yieldSplitter = getVaultYieldSplitter(vault) + if (!yieldSplitter?.enabled) { + return false + } + + return ( + (sourceVaultChainID === undefined || getVaultChainID(vault) === sourceVaultChainID) && + toAddress(yieldSplitter.sourceVaultAddress) === normalizedSourceVaultAddress + ) + }) + .sort((left, right) => getYieldModeSortKey(left).localeCompare(getYieldModeSortKey(right))) +} + +export function getSourceVaultYieldModeOptions( + sourceVault: TKongVaultInput | undefined, + allVaults: TDict +): TYieldSplitterModeOption[] { + if (!sourceVault) { + return [] + } + + const sourceVaultAddress = getVaultAddress(sourceVault) + const nativeSymbol = + getVaultYieldSplitter(sourceVault)?.depositAssetSymbol || getVaultToken(sourceVault).symbol || 'native' + const nativeName = getVaultYieldSplitter(sourceVault)?.depositAssetName || getVaultName(sourceVault) + const variantVaults = getYieldSplitterVariantVaults(sourceVaultAddress, allVaults) + + return [ + { + id: 'native', + vaultAddress: sourceVaultAddress, + label: `Compound ${nativeSymbol}`, + shortLabel: 'Native', + description: `Deposit into ${nativeName} and compound yield natively.`, + isNative: true, + vault: sourceVault + }, + ...variantVaults.map((variantVault) => { + const yieldSplitter = getVaultYieldSplitter(variantVault) + const wantLabel = yieldSplitter?.wantVaultSymbol || yieldSplitter?.wantVaultName || getVaultName(variantVault) + return { + id: toAddress(getVaultAddress(variantVault)), + vaultAddress: toAddress(getVaultAddress(variantVault)), + label: `Earn ${wantLabel}`, + shortLabel: wantLabel, + description: + yieldSplitter?.uiDescription || + `Deposit into ${getVaultName(sourceVault)} and route yield into ${wantLabel}.`, + isNative: false, + vault: variantVault + } + }) + ] +} + +function hasHeldBalanceForVault(vault: TKongVaultInput, getBalance: TBalanceGetter): boolean { + const chainID = getVaultChainID(vault) + const directBalance = getBalance({ address: getVaultAddress(vault), chainID }) + if (directBalance.raw > 0n) { + return true + } + + const staking = getVaultStaking(vault) + if (isZeroAddress(staking.address)) { + return false + } + + return getBalance({ address: staking.address, chainID }).raw > 0n +} + +export function getHeldYieldSplitterModeSummary( + sourceVault: TKongVaultInput | undefined, + allVaults: TDict, + getBalance: TBalanceGetter +): TYieldSplitterHoldingsSummary | null { + if (!sourceVault) { + return null + } + + const heldVariants = getYieldSplitterVariantVaults(getVaultAddress(sourceVault), allVaults).filter((variantVault) => + hasHeldBalanceForVault(variantVault, getBalance) + ) + + if (heldVariants.length === 0) { + return null + } + + const heldLabels = heldVariants.map((variantVault) => { + const yieldSplitter = getVaultYieldSplitter(variantVault) + return yieldSplitter?.wantVaultSymbol || yieldSplitter?.wantVaultName || getVaultName(variantVault) + }) + + if (heldLabels.length === 1) { + return { + label: `Earning ${heldLabels[0]}`, + tooltip: `This position routes yield into ${heldLabels[0]}.`, + preferredVaultAddress: getVaultAddress(heldVariants[0]) + } + } + + return { + label: `${heldLabels.length} yield modes`, + tooltip: `This position is split across ${heldLabels.join(', ')} reward routes.` + } +} + +export function getCanonicalSourceVaultAddressForRoute( + routeVaultAddress: TAddress | undefined, + allVaults: TDict +): `0x${string}` | undefined { + if (!routeVaultAddress) { + return undefined + } + + const normalizedAddress = toAddress(routeVaultAddress) + const directVault = allVaults[normalizedAddress] + const directSourceAddress = getYieldSplitterSourceVaultAddress(directVault) + + if (directSourceAddress) { + return directSourceAddress + } + + const canonicalAddress = getCanonicalHoldingsVaultAddress(normalizedAddress) + return canonicalAddress === normalizedAddress ? undefined : canonicalAddress +} diff --git a/src/components/pages/vaults/hooks/rewards/useClaimYieldSplitterRewards.ts b/src/components/pages/vaults/hooks/rewards/useClaimYieldSplitterRewards.ts new file mode 100644 index 000000000..973c0baca --- /dev/null +++ b/src/components/pages/vaults/hooks/rewards/useClaimYieldSplitterRewards.ts @@ -0,0 +1,33 @@ +import { useSimulateContract } from '@shared/hooks/useAppWagmi' + +const YIELD_SPLITTER_CLAIM_ABI = [ + { + inputs: [], + name: 'getReward', + outputs: [], + stateMutability: 'nonpayable', + type: 'function' + } +] as const + +type UseClaimYieldSplitterRewardsParams = { + splitterAddress?: `0x${string}` + chainId: number + enabled?: boolean +} + +export function useClaimYieldSplitterRewards(params: UseClaimYieldSplitterRewardsParams) { + const { splitterAddress, chainId, enabled = true } = params + + const prepare = useSimulateContract({ + // Claims stay on the splitter contract as well. YieldSplitter exposes `getReward()`, + // whereas the optional reward handler has its own `claimRewards()` interface. + address: splitterAddress, + abi: YIELD_SPLITTER_CLAIM_ABI, + functionName: 'getReward', + chainId, + query: { enabled: enabled && !!splitterAddress } + }) + + return { prepare } +} diff --git a/src/components/pages/vaults/hooks/rewards/useYieldSplitterRewards.ts b/src/components/pages/vaults/hooks/rewards/useYieldSplitterRewards.ts new file mode 100644 index 000000000..56fec2c51 --- /dev/null +++ b/src/components/pages/vaults/hooks/rewards/useYieldSplitterRewards.ts @@ -0,0 +1,130 @@ +import type { TStakingReward } from '@pages/vaults/components/widget/rewards/types' +import { useTokens } from '@pages/vaults/hooks/useTokens' +import { useYearn } from '@shared/contexts/useYearn' +import { isZeroAddress, toAddress, toNormalizedValue } from '@shared/utils' +import { useCallback, useMemo } from 'react' +import { useReadContracts } from 'wagmi' +import { resolveExecutionChainId } from '@/config/tenderly' + +const YIELD_SPLITTER_REWARDS_ABI = [ + { + inputs: [ + { internalType: 'address', name: '_account', type: 'address' }, + { internalType: 'address', name: '_rewardToken', type: 'address' } + ], + name: 'earned', + outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }], + stateMutability: 'view', + type: 'function' + } +] as const + +type UseYieldSplitterRewardsParams = { + splitterAddress?: `0x${string}` + rewardTokenAddresses: `0x${string}`[] + userAddress?: `0x${string}` + chainId: number + enabled?: boolean +} + +type UseYieldSplitterRewardsReturn = { + rewards: TStakingReward[] + isLoading: boolean + refetch: () => void +} + +export function useYieldSplitterRewards(params: UseYieldSplitterRewardsParams): UseYieldSplitterRewardsReturn { + const { splitterAddress, rewardTokenAddresses, userAddress, chainId, enabled = true } = params + const { getPrice } = useYearn() + const executionChainId = resolveExecutionChainId(chainId) + + const normalizedRewardTokenAddresses = useMemo( + () => + Array.from( + new Set(rewardTokenAddresses.map((address) => toAddress(address)).filter((address) => !isZeroAddress(address))) + ) as `0x${string}`[], + [rewardTokenAddresses] + ) + + const { + tokens, + isLoading: isLoadingTokens, + refetch: refetchTokens + } = useTokens(normalizedRewardTokenAddresses, chainId, userAddress) + + const tokenMap = useMemo( + () => + tokens.reduce((accumulator, token) => { + const address = token.address ? toAddress(token.address) : undefined + if (address) { + accumulator.set(address, token) + } + return accumulator + }, new Map()), + [tokens] + ) + + const isEnabled = enabled && !!splitterAddress && !!userAddress && normalizedRewardTokenAddresses.length > 0 + + const contracts = useMemo( + () => + isEnabled && !!executionChainId + ? normalizedRewardTokenAddresses.map((rewardTokenAddress) => ({ + // Intentionally read rewards from the splitter contract itself. + // In the ysplitter ABI, `earned(account, rewardToken)` lives on YieldSplitter, + // while `rewardHandlerAddress` points at a helper contract with a different surface. + address: splitterAddress!, + abi: YIELD_SPLITTER_REWARDS_ABI, + functionName: 'earned' as const, + args: [userAddress!, rewardTokenAddress] as const, + chainId: executionChainId + })) + : [], + [executionChainId, isEnabled, normalizedRewardTokenAddresses, splitterAddress, userAddress] + ) + + const { + data: earnedData, + isLoading: isLoadingEarned, + refetch: refetchEarned + } = useReadContracts({ + contracts, + query: { enabled: isEnabled && !!executionChainId } + }) + + const rewards = useMemo((): TStakingReward[] => { + if (!isEnabled) { + return [] + } + + return normalizedRewardTokenAddresses + .map((rewardTokenAddress, index) => { + const result = earnedData?.[index] + const amount = result?.status === 'success' ? (result.result as bigint) : 0n + const token = tokenMap.get(rewardTokenAddress) + const decimals = token?.decimals ?? 18 + const price = getPrice({ address: rewardTokenAddress, chainID: chainId }).normalized + + return { + tokenAddress: rewardTokenAddress, + symbol: token?.symbol ?? '???', + decimals, + amount, + price, + usdValue: toNormalizedValue(amount, decimals) * price + } + }) + .filter((reward) => reward.amount > 0n) + }, [chainId, earnedData, getPrice, isEnabled, normalizedRewardTokenAddresses, tokenMap]) + + const refetch = useCallback(() => { + refetchTokens() + refetchEarned() + }, [refetchEarned, refetchTokens]) + + return { + rewards, + isLoading: isLoadingTokens || isLoadingEarned, + refetch + } +} diff --git a/src/components/pages/vaults/hooks/useVaultsListModel.ts b/src/components/pages/vaults/hooks/useVaultsListModel.ts index 2ae1885c6..ac4d0ce4f 100644 --- a/src/components/pages/vaults/hooks/useVaultsListModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsListModel.ts @@ -5,6 +5,7 @@ import { type TKongVaultInput, type TKongVaultView } from '@pages/vaults/domain/kongVaultSelectors' +import { getHeldYieldSplitterModeSummary } from '@pages/vaults/domain/yieldSplitterModes' import { type TPossibleSortBy, useSortVaults } from '@pages/vaults/hooks/useSortVaults' import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { @@ -18,10 +19,12 @@ import type { TVaultAggressiveness } from '@pages/vaults/utils/vaultListFacets' import type { TVaultType } from '@pages/vaults/utils/vaultTypeCopy' import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' import { useWallet } from '@shared/contexts/useWallet' +import { useYearn } from '@shared/contexts/useYearn' import { useV2VaultFilter } from '@shared/hooks/useV2VaultFilter' import { useV3VaultFilter } from '@shared/hooks/useV3VaultFilter' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import type { TSortDirection } from '@shared/types' +import { toAddress } from '@shared/utils' import { useMemo } from 'react' type TVaultsPinnedSection = { @@ -34,6 +37,7 @@ type TVaultsListModelArgs = { listVaultType: TVaultType listChains: number[] | null listV3Types: string[] + includeYieldSplittersByDefault?: boolean listCategories: string[] | null listAggressiveness: string[] | null listUnderlyingAssets: string[] | null @@ -53,6 +57,7 @@ type TVaultsListModel = { holdingsVaults: TKongVaultInput[] availableVaults: TKongVaultInput[] vaultFlags: Record + vaultHrefOverrides: Record underlyingAssetVaults: Record pinnedSections: TVaultsPinnedSection[] pinnedVaults: TKongVaultInput[] @@ -128,6 +133,7 @@ export function useVaultsListModel({ listVaultType, listChains, listV3Types, + includeYieldSplittersByDefault = false, listCategories, listAggressiveness, listUnderlyingAssets, @@ -147,6 +153,7 @@ export function useVaultsListModel({ const isV2View = enabled && (listVaultType === 'factory' || isAllVaults) const { listVault: yvUsdVault } = useYvUsdVaults() const { getBalance } = useWallet() + const { allVaults } = useYearn() const listV2Types = useMemo( () => (listShowLegacyVaults ? ['factory', 'legacy'] : ['factory']), @@ -192,6 +199,7 @@ export function useVaultsListModel({ isV3View ? listUnderlyingAssets : null, listMinTvl, isV3View ? listShowHiddenVaults : undefined, + isV3View ? includeYieldSplittersByDefault : undefined, isV3View ) @@ -272,12 +280,49 @@ export function useVaultsListModel({ const vaultFlags = useMemo(() => { const baseFlags = selectVaultsByType(listVaultType, v3FilterResult.vaultFlags, v2FilterResult.vaultFlags) + const vaultKeys = new Set( + [...filteredVaults, ...holdingsVaults, ...availableVaults].map((vault) => getVaultKey(vault)) + ) + const yieldModeFlags = Array.from(vaultKeys).reduce< + Record + >((accumulator, vaultKey) => { + const vault = + filteredVaults.find((candidate) => getVaultKey(candidate) === vaultKey) || + holdingsVaults.find((candidate) => getVaultKey(candidate) === vaultKey) || + availableVaults.find((candidate) => getVaultKey(candidate) === vaultKey) + + if (!vault) { + return accumulator + } + + const summary = getHeldYieldSplitterModeSummary(vault, allVaults, getBalance) + if (summary) { + accumulator[vaultKey] = { + yieldModeLabel: summary.label, + yieldModeTooltip: summary.tooltip + } + } + return accumulator + }, {}) if (!yvUsdVault) { - return baseFlags + return Object.entries(baseFlags).reduce((accumulator, [key, value]) => { + accumulator[key] = { + ...value, + ...yieldModeFlags[key] + } + return accumulator + }, {}) } const yvUsdKey = getVaultKey(yvUsdVault) + const mergedBaseFlags = Object.entries(baseFlags).reduce((accumulator, [key, value]) => { + accumulator[key] = { + ...value, + ...yieldModeFlags[key] + } + return accumulator + }, {}) return { - ...baseFlags, + ...mergedBaseFlags, [yvUsdKey]: { hasHoldings: yvUsdHasHoldings, isMigratable: false, @@ -285,7 +330,44 @@ export function useVaultsListModel({ isHidden: false } } - }, [listVaultType, v3FilterResult.vaultFlags, v2FilterResult.vaultFlags, yvUsdHasHoldings, yvUsdVault]) + }, [ + allVaults, + availableVaults, + filteredVaults, + getBalance, + holdingsVaults, + listVaultType, + v3FilterResult.vaultFlags, + v2FilterResult.vaultFlags, + yvUsdHasHoldings, + yvUsdVault + ]) + + const vaultHrefOverrides = useMemo(() => { + const vaultKeys = new Set( + [...filteredVaults, ...holdingsVaults, ...availableVaults].map((vault) => getVaultKey(vault)) + ) + + return Array.from(vaultKeys).reduce>((accumulator, vaultKey) => { + const vault = + filteredVaults.find((candidate) => getVaultKey(candidate) === vaultKey) || + holdingsVaults.find((candidate) => getVaultKey(candidate) === vaultKey) || + availableVaults.find((candidate) => getVaultKey(candidate) === vaultKey) + + if (!vault) { + return accumulator + } + + const summary = getHeldYieldSplitterModeSummary(vault, allVaults, getBalance) + if (summary?.preferredVaultAddress) { + accumulator[vaultKey] = + `/vaults/${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}` + + `?yieldMode=${summary.preferredVaultAddress}` + } + + return accumulator + }, {}) + }, [allVaults, availableVaults, filteredVaults, getBalance, holdingsVaults]) const isLoadingVaultList = listVaultType === 'all' @@ -313,6 +395,7 @@ export function useVaultsListModel({ isV3View ? listUnderlyingAssets : null, listMinTvl, isV3View ? listShowHiddenVaults : undefined, + false, isV3View ) @@ -448,6 +531,7 @@ export function useVaultsListModel({ holdingsVaults, availableVaults, vaultFlags, + vaultHrefOverrides, underlyingAssetVaults, pinnedSections, pinnedVaults, diff --git a/src/components/pages/vaults/hooks/useVaultsPageModel.ts b/src/components/pages/vaults/hooks/useVaultsPageModel.ts index 0fdde0627..71ce6f030 100644 --- a/src/components/pages/vaults/hooks/useVaultsPageModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsPageModel.ts @@ -38,8 +38,10 @@ import { deriveV3Aggressiveness, expandUnderlyingAssetSelection, getUnderlyingAssetLabel, + isV3ListKind, normalizeUnderlyingAssetSymbol, - type TVaultAggressiveness + type TVaultAggressiveness, + type TVaultListKind } from '@pages/vaults/utils/vaultListFacets' import type { TVaultType } from '@pages/vaults/utils/vaultTypeCopy' import { getSupportedChainsForVaultType } from '@pages/vaults/utils/vaultTypeUtils' @@ -179,6 +181,7 @@ type TVaultsListData = { pinnedVaults: TKongVaultInput[] mainVaults: TKongVaultInput[] vaultFlags: Record + vaultHrefOverrides: Record listChains: number[] | null totalMatchingVaults: number hiddenByFiltersCount: number @@ -307,6 +310,7 @@ export function useVaultsPageModel(): TVaultsPageModel { const listShowStrategies = useDeferredValue(showStrategies) const hasDisplayedTypesParam = hasTypesParam || displayedTypes !== types const hasListTypesParam = hasTypesParam + const includeYieldSplittersByDefault = !listShowStrategies && !hasListTypesParam const resolveV3Types = useCallback( (selected: string[] | null | undefined, shouldShowStrategies: boolean, hasTypesParam: boolean): string[] => { @@ -373,6 +377,7 @@ export function useVaultsPageModel(): TVaultsPageModel { holdingsVaults, availableVaults, vaultFlags, + vaultHrefOverrides, underlyingAssetVaults, pinnedSections, pinnedVaults, @@ -385,6 +390,7 @@ export function useVaultsPageModel(): TVaultsPageModel { listVaultType, listChains, listV3Types, + includeYieldSplittersByDefault, listCategories, listAggressiveness, listUnderlyingAssets: listUnderlyingAssetsSanitized, @@ -410,7 +416,7 @@ export function useVaultsPageModel(): TVaultsPageModel { [listUnderlyingAssetsSanitized] ) const activeV3Kinds = useMemo(() => { - const kinds = new Set<'allocator' | 'strategy'>() + const kinds = new Set() if (listV3Types.includes('multi')) { kinds.add('allocator') } @@ -425,6 +431,7 @@ export function useVaultsPageModel(): TVaultsPageModel { listVaultType, listChains, listV3Types, + includeYieldSplittersByDefault, listCategories, listAggressiveness: listAggressivenessSanitized.length > 0 ? listAggressivenessSanitized : null, listUnderlyingAssets: listUnderlyingAssetsSanitized, @@ -471,7 +478,7 @@ export function useVaultsPageModel(): TVaultsPageModel { (vault: TKongVaultInput): TVaultsBlockingFilterBaseActionKey[] => { const blockingKeys = new Set() const listKind = deriveListKind(vault) - const isV3Kind = listKind === 'allocator' || listKind === 'strategy' + const isV3Kind = isV3ListKind(listKind) const isV2Kind = listKind === 'factory' || listKind === 'legacy' if ((listVaultType === 'v3' && isV2Kind) || (listVaultType === 'factory' && isV3Kind)) { @@ -512,10 +519,11 @@ export function useVaultsPageModel(): TVaultsPageModel { } if (isV3Kind) { - if (!listShowStrategies && listKind === 'strategy') { + if (!listShowStrategies && (listKind === 'strategy' || listKind === 'yieldSplitter')) { blockingKeys.add('showStrategies') } - if (isTypesFilterBlockingResults && !activeV3Kinds.has(listKind)) { + const typeFilterKind = listKind === 'yieldSplitter' ? 'strategy' : listKind + if (isTypesFilterBlockingResults && !activeV3Kinds.has(typeFilterKind)) { blockingKeys.add('showAllTypes') } } @@ -590,7 +598,7 @@ export function useVaultsPageModel(): TVaultsPageModel { ) const resolveApyDisplayVariant = useCallback((vault: TKongVaultInput): 'default' | 'factory-list' => { const listKind = deriveListKind(vault) - return listKind === 'allocator' || listKind === 'strategy' ? 'default' : 'factory-list' + return isV3ListKind(listKind) ? 'default' : 'factory-list' }, []) const handleChainsChange = useCallback( (nextChains: number[] | null): void => { @@ -1135,6 +1143,7 @@ export function useVaultsPageModel(): TVaultsPageModel { pinnedVaults, mainVaults, vaultFlags, + vaultHrefOverrides, listChains, totalMatchingVaults, hiddenByFiltersCount, diff --git a/src/components/pages/vaults/index.tsx b/src/components/pages/vaults/index.tsx index afcf5e970..1fe15d2fc 100644 --- a/src/components/pages/vaults/index.tsx +++ b/src/components/pages/vaults/index.tsx @@ -96,6 +96,7 @@ export default function Index(): ReactElement { pinnedVaults, mainVaults, vaultFlags, + vaultHrefOverrides, listChains, totalMatchingVaults, hiddenByFiltersCount, @@ -360,6 +361,7 @@ export default function Index(): ReactElement { key={section.key} vaults={section.vaults} vaultFlags={vaultFlags} + vaultHrefOverrides={vaultHrefOverrides} resolveApyDisplayVariant={resolveApyDisplayVariant} compareVaultKeys={isCompareMode ? compareVaultKeys : undefined} onToggleCompare={isCompareMode ? handleToggleCompare : undefined} @@ -389,6 +391,7 @@ export default function Index(): ReactElement { = { @@ -98,6 +99,9 @@ export function deriveListKind(vault: TKongVaultInput): TVaultListKind { if (isAllocatorVaultOverride(vault)) { return 'allocator' } + if (isYieldSplitterVault(vault)) { + return 'yieldSplitter' + } const version = getVaultVersion(vault) const isV3 = Boolean(version.startsWith('3') || version.startsWith('~3')) const kind = getVaultKind(vault) @@ -115,6 +119,10 @@ export function deriveListKind(vault: TKongVaultInput): TVaultListKind { return 'legacy' } +export function isV3ListKind(kind: TVaultListKind): boolean { + return kind === 'allocator' || kind === 'strategy' || kind === 'yieldSplitter' +} + function getAggressivenessForRiskLevel(value: number): TVaultAggressiveness | null { switch (value) { case -1: diff --git a/src/components/pages/vaults/utils/vaultTagCopy.ts b/src/components/pages/vaults/utils/vaultTagCopy.ts index fa88b554d..87cff8ccc 100644 --- a/src/components/pages/vaults/utils/vaultTagCopy.ts +++ b/src/components/pages/vaults/utils/vaultTagCopy.ts @@ -49,6 +49,9 @@ export function getCategoryDescription(category?: string | null): string | null } export function getProductTypeDescription(listKind: TVaultListKind): string { + if (listKind === 'yieldSplitter') { + return 'Yield splitters accept one deposit asset, keep principal in a source vault, and stream yield into a different vault asset.' + } if (listKind === 'legacy') { return 'These vaults use a Legacy Yearn vault architecture. They were previously called "v2 Vaults").' } @@ -58,12 +61,15 @@ export function getProductTypeDescription(listKind: TVaultListKind): string { return 'Single-asset vaults accept one token and allocate it across strategies. They were previously called "v3 Vaults".' } -export function getKindDescription(kindType?: 'multi' | 'single', kindLabel?: string): string { +export function getKindDescription(kindType?: 'multi' | 'single' | 'route', kindLabel?: string): string { if (kindType === 'multi') { return 'Allocator vaults route deposits across multiple strategies.' } if (kindType === 'single') { return 'Strategy vaults contain a single active strategy and are allocated to by Allocator vaults.' } + if (kindType === 'route') { + return 'Vault-to-vault routes accept deposits in one asset and direct yield into a separate destination vault.' + } return kindLabel ? `${kindLabel} vault classification from Yearn.` : 'Vault strategy classification from Yearn.' } diff --git a/src/components/shared/hooks/useFetchYearnVaults.ts b/src/components/shared/hooks/useFetchYearnVaults.ts index 225ae09cd..98e00c147 100644 --- a/src/components/shared/hooks/useFetchYearnVaults.ts +++ b/src/components/shared/hooks/useFetchYearnVaults.ts @@ -1,4 +1,9 @@ -import { patchYBoldVaults } from '@pages/vaults/domain/normalizeVault' +import { + filterCatalogYieldSplitterVaults, + patchYBoldVaults, + primeYieldSplitterHoldingsAliases +} from '@pages/vaults/domain/normalizeVault' +import { applyYieldSplitterFallbacks } from '@pages/vaults/domain/yieldSplitterFallback' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' import { useDeepCompareMemo } from '@react-hookz/web' import { fetchWithSchema, getFetchQueryKey, useFetch } from '@shared/hooks/useFetch' @@ -75,11 +80,13 @@ function useFetchYearnVaults( }, [filteredByChain]) const patchedAllVaultsObject = useDeepCompareMemo((): TDict => { - return patchYBoldVaults(allVaultsObject) + const patchedVaults = patchYBoldVaults(applyYieldSplitterFallbacks(allVaultsObject)) + primeYieldSplitterHoldingsAliases(patchedVaults) + return patchedVaults }, [allVaultsObject]) const patchedCatalogVaultsObject = useDeepCompareMemo((): TDict => { - return patchYBoldVaults(catalogVaultsObject) + return patchYBoldVaults(filterCatalogYieldSplitterVaults(applyYieldSplitterFallbacks(catalogVaultsObject))) }, [catalogVaultsObject]) return { diff --git a/src/components/shared/hooks/useV3VaultFilter.test.ts b/src/components/shared/hooks/useV3VaultFilter.test.ts new file mode 100644 index 000000000..11b73b8d1 --- /dev/null +++ b/src/components/shared/hooks/useV3VaultFilter.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { matchesSelectedV3Kind } from './useV3VaultFilter.utils' + +describe('matchesSelectedV3Kind', () => { + it('keeps yield splitters visible in the implicit default V3 view', () => { + expect( + matchesSelectedV3Kind({ + kind: 'yieldSplitter', + types: ['multi'], + hasUserHoldings: false, + includeYieldSplittersByDefault: true + }) + ).toBe(true) + + expect( + matchesSelectedV3Kind({ + kind: 'strategy', + types: ['multi'], + hasUserHoldings: false, + includeYieldSplittersByDefault: true + }) + ).toBe(false) + }) + + it('still hides yield splitters for an explicit multi-only filter', () => { + expect( + matchesSelectedV3Kind({ + kind: 'yieldSplitter', + types: ['multi'], + hasUserHoldings: false, + includeYieldSplittersByDefault: false + }) + ).toBe(false) + }) + + it('treats yield splitters as part of the single-asset bucket when that filter is active', () => { + expect( + matchesSelectedV3Kind({ + kind: 'yieldSplitter', + types: ['single'], + hasUserHoldings: false, + includeYieldSplittersByDefault: false + }) + ).toBe(true) + }) +}) diff --git a/src/components/shared/hooks/useV3VaultFilter.ts b/src/components/shared/hooks/useV3VaultFilter.ts index 19d6b13c5..5e4759752 100644 --- a/src/components/shared/hooks/useV3VaultFilter.ts +++ b/src/components/shared/hooks/useV3VaultFilter.ts @@ -27,6 +27,7 @@ import { useWallet } from '@shared/contexts/useWallet' import { useYearn } from '@shared/contexts/useYearn' import { isZeroAddress } from '@shared/utils' import { useMemo } from 'react' +import { matchesSelectedV3Kind } from './useV3VaultFilter.utils' import { createCheckHasAvailableBalance, createCheckHasHoldings, @@ -80,6 +81,7 @@ export function useV3VaultFilter( underlyingAssets?: string[] | null, minTvl?: number, showHiddenVaults?: boolean, + includeYieldSplittersByDefault?: boolean, enabled?: boolean ): TV3VaultFilterResult { const { vaults, allVaults, getPrice, isLoadingVaultList } = useYearn() @@ -277,7 +279,6 @@ export function useV3VaultFilter( const hasChainFilter = Boolean(chains?.length) const hasCategoryFilter = Boolean(categories?.length) const hasAggressivenessFilter = Boolean(aggressiveness?.length) - const hasTypeFilter = Boolean(types?.length) const hasUnderlyingAssetFilter = normalizedUnderlyingAssets.size > 0 const matchesSearch = (searchableText: string): boolean => { @@ -353,13 +354,14 @@ export function useV3VaultFilter( const shouldIncludeByCategory = hasUserHoldings || !hasCategoryFilter || Boolean(categories?.includes(category)) const isPinnedByUserContext = hasUserHoldings || isMigratableVault || isRetiredVault - const isStrategy = kind === 'strategy' - const shouldIncludeByFeaturedGate = showHiddenVaults || isStrategy || isFeatured || isPinnedByUserContext - const shouldIncludeByKind = - hasUserHoldings || - !hasTypeFilter || - (Boolean(types?.includes('multi')) && kind === 'allocator') || - (Boolean(types?.includes('single')) && kind === 'strategy') + const isStrategyLike = kind === 'strategy' || kind === 'yieldSplitter' + const shouldIncludeByFeaturedGate = showHiddenVaults || isStrategyLike || isFeatured || isPinnedByUserContext + const shouldIncludeByKind = matchesSelectedV3Kind({ + kind, + types, + hasUserHoldings, + includeYieldSplittersByDefault: Boolean(includeYieldSplittersByDefault) + }) const shouldIncludeByAggressiveness = hasUserHoldings || !hasAggressivenessFilter || @@ -412,6 +414,7 @@ export function useV3VaultFilter( minTvlValue, holdingsVaults, showHiddenVaults, + includeYieldSplittersByDefault, searchRegex, lowercaseSearch, isSearchEnabled diff --git a/src/components/shared/hooks/useV3VaultFilter.utils.ts b/src/components/shared/hooks/useV3VaultFilter.utils.ts new file mode 100644 index 000000000..608a8e64c --- /dev/null +++ b/src/components/shared/hooks/useV3VaultFilter.utils.ts @@ -0,0 +1,29 @@ +import type { TVaultListKind } from '@pages/vaults/utils/vaultListFacets' + +type TMatchesSelectedV3Kind = { + kind: TVaultListKind + types: string[] | null + hasUserHoldings: boolean + includeYieldSplittersByDefault: boolean +} + +export function matchesSelectedV3Kind({ + kind, + types, + hasUserHoldings, + includeYieldSplittersByDefault +}: TMatchesSelectedV3Kind): boolean { + if (hasUserHoldings || !types?.length) { + return true + } + + if (Boolean(types.includes('multi')) && kind === 'allocator') { + return true + } + + if (Boolean(types.includes('single')) && (kind === 'strategy' || kind === 'yieldSplitter')) { + return true + } + + return includeYieldSplittersByDefault && kind === 'yieldSplitter' +} diff --git a/src/components/shared/hooks/useVaultFilterUtils.test.ts b/src/components/shared/hooks/useVaultFilterUtils.test.ts index b749b5f85..2be72ae88 100644 --- a/src/components/shared/hooks/useVaultFilterUtils.test.ts +++ b/src/components/shared/hooks/useVaultFilterUtils.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' -import { getVaultHoldingsUsdValue, matchesSelectedChains } from './useVaultFilterUtils' +import { primeYieldSplitterHoldingsAliases } from '@pages/vaults/domain/normalizeVault' +import { afterEach, describe, expect, it } from 'vitest' +import { getVaultHoldingsUsdValue, isV3Vault, matchesSelectedChains } from './useVaultFilterUtils' const VAULT_ADDRESS = '0x8589462548984c5C0f2C0140FB276351B5a77fe1' const ASSET_ADDRESS = '0x0000000000000000000000000000000000000002' @@ -49,6 +50,10 @@ function makeStrategyVault() { } as any } +afterEach(() => { + primeYieldSplitterHoldingsAliases({}) +}) + describe('getVaultHoldingsUsdValue', () => { it('values list-only holdings from list pricePerShare when share price is unavailable', () => { const vault = makeStrategyVault() @@ -68,6 +73,53 @@ describe('getVaultHoldingsUsdValue', () => { expect(value).toBeCloseTo(2.1, 8) }) + + it('rolls yield splitter share balances up into the canonical source vault value', () => { + const sourceVault = makeStrategyVault() + const splitterVault = { + ...makeStrategyVault(), + address: '0x9999999999999999999999999999999999999999', + name: 'USDC vault earning ETH', + symbol: 'ysUSDC', + yieldSplitter: { + enabled: true, + sourceVaultAddress: sourceVault.address, + sourceVaultName: sourceVault.name, + sourceVaultSymbol: sourceVault.symbol, + wantVaultAddress: '0x7777777777777777777777777777777777777777', + wantVaultName: 'ETH Vault', + wantVaultSymbol: 'yvETH', + depositAssetAddress: ASSET_ADDRESS, + depositAssetName: 'USD Asset', + depositAssetSymbol: 'USDC', + rewardTokenAddresses: [] + } + } as any + + const allVaults = { + [sourceVault.address.toLowerCase()]: sourceVault, + [splitterVault.address.toLowerCase()]: splitterVault + } + + primeYieldSplitterHoldingsAliases(allVaults as any) + + const value = getVaultHoldingsUsdValue( + sourceVault, + ({ address }) => ({ value: address.toLowerCase() === splitterVault.address.toLowerCase() ? 0 : undefined }), + ({ address }) => ({ + raw: address.toLowerCase() === splitterVault.address.toLowerCase() ? 2n * 10n ** 18n : 0n, + normalized: address.toLowerCase() === splitterVault.address.toLowerCase() ? 2 : 0, + display: address.toLowerCase() === splitterVault.address.toLowerCase() ? '2' : '0', + decimals: 18 + }), + ({ address }) => ({ + normalized: address.toLowerCase() === ASSET_ADDRESS.toLowerCase() ? 1 : 0 + }), + { allVaults: allVaults as any } + ) + + expect(value).toBeCloseTo(2.1, 8) + }) }) describe('matchesSelectedChains', () => { @@ -81,3 +133,38 @@ describe('matchesSelectedChains', () => { expect(matchesSelectedChains(10, [1])).toBe(false) }) }) + +describe('isV3Vault', () => { + it('treats yield splitters as V3 candidates even when apiVersion metadata is missing', () => { + const splitterVault = { + chainId: 1, + address: VAULT_ADDRESS, + name: 'Yield Splitter', + symbol: 'ysUSDC', + decimals: 18, + asset: { + address: ASSET_ADDRESS, + name: 'USD Asset', + symbol: 'USDC', + decimals: 6 + }, + kind: 'Single Strategy', + type: 'Standard', + yieldSplitter: { + enabled: true, + sourceVaultAddress: '0x3333333333333333333333333333333333333333', + sourceVaultName: 'Source Vault', + sourceVaultSymbol: 'yvSRC', + wantVaultAddress: '0x4444444444444444444444444444444444444444', + wantVaultName: 'Want Vault', + wantVaultSymbol: 'yvWANT', + depositAssetAddress: ASSET_ADDRESS, + depositAssetName: 'USD Asset', + depositAssetSymbol: 'USDC', + rewardTokenAddresses: [] + } + } as any + + expect(isV3Vault(splitterVault, false)).toBe(true) + }) +}) diff --git a/src/components/shared/hooks/useVaultFilterUtils.ts b/src/components/shared/hooks/useVaultFilterUtils.ts index 459912c2e..c420224d6 100644 --- a/src/components/shared/hooks/useVaultFilterUtils.ts +++ b/src/components/shared/hooks/useVaultFilterUtils.ts @@ -9,9 +9,11 @@ import { getVaultToken, getVaultTVL, getVaultVersion, + isYieldSplitterVault, type TKongVault, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { getCanonicalHoldingsVaultAddress } from '@pages/vaults/domain/normalizeVault' import { getNativeTokenWrapperContract } from '@pages/vaults/utils/nativeTokens' import type { TDict } from '@shared/types' import type { TAddress } from '@shared/types/address' @@ -36,6 +38,8 @@ export type TVaultFlags = { isRetired: boolean isHidden: boolean isNotYearn?: boolean + yieldModeLabel?: string + yieldModeTooltip?: string } type TTokenAndChain = { address: TAddress; chainID: number } @@ -49,8 +53,6 @@ type TVaultHoldingsUsdOptions = { stakingConvertedAssets?: TStakingConversionMap } -const zeroNormalizedBalance = toNormalizedBN(0n, 18) - const getVaultSharePriceUsd = (vault: TVaultLike, getPrice: TPriceGetter): number => { const chainID = getVaultChainID(vault) const vaultAddress = getVaultAddress(vault) @@ -144,24 +146,10 @@ export function getVaultHoldingsUsdValue( options?: TVaultHoldingsUsdOptions ): number { const chainID = getVaultChainID(vault) - const address = getVaultAddress(vault) - const staking = getVaultStaking(vault) + const address = toAddress(getVaultAddress(vault)) const allVaults = options?.allVaults ?? {} const stakingConvertedAssets = options?.stakingConvertedAssets ?? {} - - const vaultToken = getToken({ address, chainID }) - const vaultDirectValue = Number(vaultToken.value || 0) - const vaultShareBalance = getBalance({ address, chainID }) - const vaultShares = Number(vaultShareBalance.normalized || 0) - - const canUseStaking = !isZeroAddress(staking.address) - const stakingToken = canUseStaking ? getToken({ address: staking.address, chainID }) : null - const stakingDirectValue = Number(stakingToken?.value || 0) - const stakingShareBalance = canUseStaking ? getBalance({ address: staking.address, chainID }) : zeroNormalizedBalance - const stakingShares = Number(stakingShareBalance.normalized || 0) - const stakingConversionKey = `${chainID}/${toAddress(staking.address)}` - const convertedStakingAssets = stakingConvertedAssets[stakingConversionKey] - const stakingVault = canUseStaking ? allVaults[toAddress(staking.address)] : undefined + const canonicalAddress = getCanonicalHoldingsVaultAddress(address) const resolvePositionValue = (positionVault: TVaultLike, directValue: number, shares: number): number => { if (Number.isFinite(directValue) && directValue > 0) { @@ -195,28 +183,61 @@ export function getVaultHoldingsUsdValue( return 0 } - const resolveStakingValue = (): number => { - if (!canUseStaking) { - return 0 + const positionVaults = Object.values(allVaults).filter((candidateVault) => { + return ( + getVaultChainID(candidateVault) === chainID && + getCanonicalHoldingsVaultAddress(getVaultAddress(candidateVault)) === canonicalAddress + ) + }) + const resolvedPositionVaults = positionVaults.length > 0 ? positionVaults : [vault] + const directPositionAddresses = new Set( + resolvedPositionVaults.map((candidateVault) => toAddress(getVaultAddress(candidateVault))) + ) + + const totalValue = resolvedPositionVaults.reduce((total, candidateVault) => { + const candidateChainID = getVaultChainID(candidateVault) + const candidateAddress = toAddress(getVaultAddress(candidateVault)) + const candidateToken = getToken({ address: candidateAddress, chainID: candidateChainID }) + const directValue = Number(candidateToken.value || 0) + const directShares = Number(getBalance({ address: candidateAddress, chainID: candidateChainID }).normalized || 0) + const directPositionValue = resolvePositionValue(candidateVault, directValue, directShares) + + const staking = getVaultStaking(candidateVault) + const stakingAddress = !isZeroAddress(staking.address) ? toAddress(staking.address) : undefined + if (!stakingAddress || directPositionAddresses.has(stakingAddress)) { + return total + directPositionValue } - if (Number.isFinite(stakingDirectValue) && stakingDirectValue > 0) { - return stakingDirectValue - } + const stakingToken = getToken({ address: stakingAddress, chainID: candidateChainID }) + const stakingDirectValue = Number(stakingToken.value || 0) + const stakingShares = Number(getBalance({ address: stakingAddress, chainID: candidateChainID }).normalized || 0) + const stakingConversionKey = `${candidateChainID}/${stakingAddress}` + const convertedStakingAssets = stakingConvertedAssets[stakingConversionKey] + const stakingVault = allVaults[stakingAddress] - if (stakingVault) { - return resolvePositionValue(stakingVault, 0, stakingShares) - } + const stakingPositionValue = (() => { + if (Number.isFinite(stakingDirectValue) && stakingDirectValue > 0) { + return stakingDirectValue + } - if (convertedStakingAssets !== undefined && convertedStakingAssets > 0n) { - const convertedShares = toNormalizedBN(convertedStakingAssets, getVaultDecimals(vault)).normalized - return resolvePositionValue(vault, 0, convertedShares) - } + if (stakingVault) { + return resolvePositionValue(stakingVault, 0, stakingShares) + } - return resolvePositionValue(vault, 0, stakingShares) - } + if (convertedStakingAssets !== undefined && convertedStakingAssets > 0n) { + const convertedShares = toNormalizedBN( + convertedStakingAssets, + getVaultDecimals(stakingVault ?? candidateVault) + ).normalized + return resolvePositionValue(stakingVault ?? candidateVault, 0, convertedShares) + } + + return resolvePositionValue(candidateVault, 0, stakingShares) + })() + + return total + directPositionValue + stakingPositionValue + }, 0) - const totalValue = resolvePositionValue(vault, vaultDirectValue, vaultShares) + resolveStakingValue() if (!Number.isFinite(totalValue)) { return 0 } @@ -247,7 +268,7 @@ export function matchesSelectedChains(chainID: number, chains: number[] | null | export function isV3Vault(vault: TVaultLike, isAllocatorOverride: boolean): boolean { const version = getVaultVersion(vault) - return version.startsWith('3') || version.startsWith('~3') || isAllocatorOverride + return version.startsWith('3') || version.startsWith('~3') || isAllocatorOverride || isYieldSplitterVault(vault) } export function extractHoldingsVaults(vaultMap: Map): TKongVault[] { diff --git a/src/components/shared/utils/schemas/kongVaultListSchema.ts b/src/components/shared/utils/schemas/kongVaultListSchema.ts index e01b48b16..7eb0cf1e7 100644 --- a/src/components/shared/utils/schemas/kongVaultListSchema.ts +++ b/src/components/shared/utils/schemas/kongVaultListSchema.ts @@ -24,6 +24,27 @@ const stakingRewardSchema = z }) .passthrough() +const yieldSplitterSchema = z + .object({ + enabled: z.literal(true), + sourceVaultAddress: addressSchema, + sourceVaultName: z.string().optional().catch(''), + sourceVaultSymbol: z.string().optional().catch(''), + wantVaultAddress: addressSchema, + wantVaultName: z.string().optional().catch(''), + wantVaultSymbol: z.string().optional().catch(''), + depositAssetAddress: addressSchema.optional().catch('0x0000000000000000000000000000000000000000'), + depositAssetName: z.string().optional().catch(''), + depositAssetSymbol: z.string().optional().catch(''), + rewardTokenAddresses: z.array(addressSchema).optional().default([]).catch([]), + rewardHandlerAddress: addressSchema.optional().catch('0x0000000000000000000000000000000000000000'), + tokenizedStrategyAddress: addressSchema.optional().catch('0x0000000000000000000000000000000000000000'), + displayType: z.string().optional().default('Yield Splitter').catch('Yield Splitter'), + displayKind: z.string().optional().default('Vault-to-Vault').catch('Vault-to-Vault'), + uiDescription: z.string().optional().default('').catch('') + }) + .passthrough() + export const kongVaultListItemSchema = z.object({ chainId: z.number(), address: addressSchema, @@ -127,7 +148,9 @@ export const kongVaultListItemSchema = z.object({ }) .nullish(), - pricePerShare: coerceNullableBigNumberish.optional() + pricePerShare: coerceNullableBigNumberish.optional(), + + yieldSplitter: yieldSplitterSchema.optional() }) export const kongVaultListSchema = z.array(kongVaultListItemSchema) @@ -135,3 +158,4 @@ export const kongVaultListSchema = z.array(kongVaultListItemSchema) export type TKongVaultListItem = z.infer export type TKongVaultList = z.infer export type TKongVaultListItemStakingReward = z.infer +export type TKongVaultListItemYieldSplitter = z.infer diff --git a/src/components/shared/utils/schemas/kongVaultSnapshotSchema.ts b/src/components/shared/utils/schemas/kongVaultSnapshotSchema.ts index 6af39b4b9..543a2b136 100644 --- a/src/components/shared/utils/schemas/kongVaultSnapshotSchema.ts +++ b/src/components/shared/utils/schemas/kongVaultSnapshotSchema.ts @@ -278,6 +278,27 @@ const snapshotStakingSchema = z .nullable() .optional() +const snapshotYieldSplitterSchema = z + .object({ + enabled: z.literal(true), + sourceVaultAddress: addressSchema, + sourceVaultName: z.string().optional().default('').catch(''), + sourceVaultSymbol: z.string().optional().default('').catch(''), + wantVaultAddress: addressSchema, + wantVaultName: z.string().optional().default('').catch(''), + wantVaultSymbol: z.string().optional().default('').catch(''), + depositAssetAddress: addressSchema.optional().catch(zeroAddress), + depositAssetName: z.string().optional().default('').catch(''), + depositAssetSymbol: z.string().optional().default('').catch(''), + rewardTokenAddresses: z.array(addressSchema).optional().default([]).catch([]), + rewardHandlerAddress: addressSchema.optional().catch(zeroAddress), + tokenizedStrategyAddress: addressSchema.optional().catch(zeroAddress), + displayType: z.string().optional().default('Yield Splitter').catch('Yield Splitter'), + displayKind: z.string().optional().default('Vault-to-Vault').catch('Vault-to-Vault'), + uiDescription: z.string().optional().default('').catch('') + }) + .passthrough() + export const kongVaultSnapshotSchema = z .object({ address: addressSchema, @@ -300,7 +321,8 @@ export const kongVaultSnapshotSchema = z debts: z.array(snapshotDebtSchema).optional().default([]), strategies: z.array(addressSchema).optional().default([]), staking: snapshotStakingSchema, - inclusion: z.record(z.string(), z.boolean()).optional() + inclusion: z.record(z.string(), z.boolean()).optional(), + yieldSplitter: snapshotYieldSplitterSchema.optional() }) .passthrough() @@ -309,3 +331,4 @@ export type TKongVaultSnapshotDebt = z.infer export type TKongVaultSnapshotComposition = z.infer export type TKongVaultSnapshotStaking = z.infer export type TKongVaultSnapshotStakingReward = z.infer +export type TKongVaultSnapshotYieldSplitter = z.infer