From 7f62b0aee4cf484448d4bc1835228d7b8db84a39 Mon Sep 17 00:00:00 2001 From: Ross Date: Wed, 11 Mar 2026 18:42:49 -0400 Subject: [PATCH] fix: rebuild staking withdraw path on release --- .../hooks/portfolioVisibility.test.ts | 107 +++ .../portfolio/hooks/portfolioVisibility.ts | 9 + .../portfolio/hooks/usePortfolioModel.ts | 169 ++--- .../hooks/useVaultWithStakingRewards.ts | 3 +- src/components/pages/portfolio/index.tsx | 6 +- .../pages/vaults/[chainID]/[address].tsx | 71 +- .../vaults/components/list/VaultsListRow.tsx | 36 +- .../components/widget/deposit/index.tsx | 4 + .../pages/vaults/components/widget/index.tsx | 1 + .../components/widget/migrate/index.tsx | 4 + .../widget/shared/TransactionOverlay.test.ts | 52 ++ .../widget/shared/TransactionOverlay.tsx | 665 ++++++++++++------ .../shared/transactionOverlay.helpers.ts | 36 + .../withdraw/WithdrawDetailsOverlay.tsx | 12 + .../components/widget/withdraw/index.tsx | 313 +++++---- .../components/widget/withdraw/types.ts | 3 +- .../widget/withdraw/useWithdrawFlow.ts | 51 +- .../withdraw/useWithdrawNotifications.ts | 45 +- .../widget/withdraw/useWithdrawRoute.ts | 13 +- .../withdraw/withdrawStepHelpers.test.ts | 181 +++++ .../widget/withdraw/withdrawStepHelpers.ts | 203 ++++++ .../vaults/domain/kongVaultSelectors.test.ts | 227 +++--- .../pages/vaults/domain/kongVaultSelectors.ts | 16 +- .../vaults/domain/normalizeVault.test.ts | 19 + .../pages/vaults/domain/normalizeVault.ts | 20 +- .../pages/vaults/domain/vaultWarnings.test.ts | 66 ++ .../pages/vaults/domain/vaultWarnings.ts | 23 + .../hooks/actions/stakingAdapter.test.ts | 188 +++++ .../vaults/hooks/actions/stakingAdapter.ts | 349 +++++++++ .../vaults/hooks/actions/useDirectStake.ts | 78 +- .../vaults/hooks/actions/useDirectUnstake.ts | 40 +- .../vaults/hooks/actions/useDirectWithdraw.ts | 117 ++- .../vaults/hooks/actions/useEnsoDeposit.ts | 2 +- .../pages/vaults/hooks/useEnsoOrder.ts | 3 +- .../pages/vaults/hooks/useSortVaults.ts | 70 +- .../pages/vaults/hooks/useTokens.ts | 52 +- .../pages/vaults/hooks/useVaultUserData.ts | 109 ++- .../pages/vaults/hooks/useVaultsPageModel.ts | 2 +- .../vaults/hooks/vaultsFiltersStorage.ts | 15 + .../pages/vaults/utils/holdingsValue.ts | 53 ++ .../pages/vaults/utils/vaultTagCopy.ts | 1 + src/components/shared/contexts/useWallet.tsx | 85 ++- .../shared/contexts/useYearn.helper.tsx | 141 ++-- src/components/shared/contexts/useYearn.tsx | 5 +- .../shared/hooks/balanceDiscoveryFallback.ts | 9 + .../shared/hooks/useBalances.multichains.ts | 25 + .../shared/hooks/useBalancesCombined.test.ts | 40 ++ .../shared/hooks/useBalancesCombined.ts | 221 ++++-- .../shared/hooks/useBalancesRouting.test.ts | 127 ++++ .../shared/hooks/useBalancesRouting.ts | 94 +++ .../shared/hooks/useEnsoBalances.ts | 9 +- .../shared/hooks/useFetchYearnVaults.test.ts | 37 + .../shared/hooks/useFetchYearnVaults.ts | 55 +- .../hooks/useStakingAssetConversions.ts | 130 ++++ .../shared/hooks/useV2VaultFilter.ts | 369 +++++++++- .../shared/hooks/useV3VaultFilter.ts | 407 ++++++++++- .../shared/hooks/useVaultFilterUtils.test.ts | 71 ++ .../shared/hooks/useVaultFilterUtils.ts | 168 ++++- src/components/shared/utils/format.ts | 22 +- .../utils/schemas/kongVaultListSchema.ts | 26 +- src/components/shared/utils/vaultApy.test.ts | 4 +- 61 files changed, 4511 insertions(+), 968 deletions(-) create mode 100644 src/components/pages/portfolio/hooks/portfolioVisibility.test.ts create mode 100644 src/components/pages/portfolio/hooks/portfolioVisibility.ts create mode 100644 src/components/pages/vaults/components/widget/shared/TransactionOverlay.test.ts create mode 100644 src/components/pages/vaults/components/widget/shared/transactionOverlay.helpers.ts create mode 100644 src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts create mode 100644 src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.ts create mode 100644 src/components/pages/vaults/domain/normalizeVault.test.ts create mode 100644 src/components/pages/vaults/domain/vaultWarnings.test.ts create mode 100644 src/components/pages/vaults/domain/vaultWarnings.ts create mode 100644 src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts create mode 100644 src/components/pages/vaults/hooks/actions/stakingAdapter.ts create mode 100644 src/components/pages/vaults/hooks/vaultsFiltersStorage.ts create mode 100644 src/components/pages/vaults/utils/holdingsValue.ts create mode 100644 src/components/shared/hooks/balanceDiscoveryFallback.ts create mode 100644 src/components/shared/hooks/useBalancesCombined.test.ts create mode 100644 src/components/shared/hooks/useBalancesRouting.test.ts create mode 100644 src/components/shared/hooks/useBalancesRouting.ts create mode 100644 src/components/shared/hooks/useFetchYearnVaults.test.ts create mode 100644 src/components/shared/hooks/useStakingAssetConversions.ts create mode 100644 src/components/shared/hooks/useVaultFilterUtils.test.ts diff --git a/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts b/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts new file mode 100644 index 000000000..cfeb80708 --- /dev/null +++ b/src/components/pages/portfolio/hooks/portfolioVisibility.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { filterVisiblePortfolioHoldings } from './portfolioVisibility' + +function makeVault(address: string, isHidden: boolean) { + return { + chainID: 1, + address, + name: `Vault ${address.slice(-4)}`, + symbol: 'yvTEST', + version: '3.0.0', + type: 'Standard', + kind: 'Single Strategy', + decimals: 18, + token: { + address, + name: 'Vault Token', + symbol: 'yvTEST', + description: '', + decimals: 18 + }, + tvl: { + totalAssets: 0n, + tvl: 0, + price: 0 + }, + apr: { + type: 'oracle', + netAPR: 0, + fees: { + performance: 0, + withdrawal: 0, + management: 0 + }, + extra: { + stakingRewardsAPR: 0, + gammaRewardAPR: 0 + }, + points: { + weekAgo: 0, + monthAgo: 0, + inception: 0 + }, + pricePerShare: { + today: 1, + weekAgo: 1, + monthAgo: 1 + }, + forwardAPR: { + type: 'oracle', + netAPR: 0, + composite: { + boost: 0, + poolAPY: 0, + boostedAPR: 0, + baseAPR: 0, + cvxAPR: 0, + rewardsAPR: 0, + v3OracleCurrentAPR: 0, + v3OracleStratRatioAPR: 0, + keepCRV: 0, + keepVELO: 0, + cvxKeepCRV: 0 + } + } + }, + featuringScore: 0, + strategies: [], + staking: { + address: null, + available: false, + source: '', + rewards: [] + }, + migration: { + available: false, + address: '0x0000000000000000000000000000000000000000', + contract: '0x0000000000000000000000000000000000000000' + }, + info: { + sourceURL: '', + riskLevel: 1, + riskScore: [], + riskScoreComment: '', + uiNotice: '', + isRetired: false, + isBoosted: false, + isHighlighted: false, + isHidden + } + } as any +} + +describe('filterVisiblePortfolioHoldings', () => { + it('hides hidden vaults when the persisted hidden-vault filter is off', () => { + const visible = makeVault('0x1111111111111111111111111111111111111111', false) + const hidden = makeVault('0x2222222222222222222222222222222222222222', true) + + expect(filterVisiblePortfolioHoldings([visible, hidden], false)).toEqual([visible]) + }) + + it('keeps hidden vaults when the persisted hidden-vault filter is on', () => { + const visible = makeVault('0x1111111111111111111111111111111111111111', false) + const hidden = makeVault('0x2222222222222222222222222222222222222222', true) + + expect(filterVisiblePortfolioHoldings([visible, hidden], true)).toEqual([visible, hidden]) + }) +}) diff --git a/src/components/pages/portfolio/hooks/portfolioVisibility.ts b/src/components/pages/portfolio/hooks/portfolioVisibility.ts new file mode 100644 index 000000000..bc30e225d --- /dev/null +++ b/src/components/pages/portfolio/hooks/portfolioVisibility.ts @@ -0,0 +1,9 @@ +import { getVaultInfo, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' + +export function filterVisiblePortfolioHoldings(vaults: TKongVault[], showHiddenVaults: boolean): TKongVault[] { + if (showHiddenVaults) { + return vaults + } + + return vaults.filter((vault) => !Boolean(getVaultInfo(vault)?.isHidden)) +} diff --git a/src/components/pages/portfolio/hooks/usePortfolioModel.ts b/src/components/pages/portfolio/hooks/usePortfolioModel.ts index 6a4f314ba..5483cab36 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioModel.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.ts @@ -9,16 +9,20 @@ import { getVaultStaking, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { getCanonicalHoldingsVaultAddress } from '@pages/vaults/domain/normalizeVault' +import { isNonYearnErc4626Vault } from '@pages/vaults/domain/vaultWarnings' import { type TPossibleSortBy, useSortVaults } from '@pages/vaults/hooks/useSortVaults' +import { usePersistedShowHiddenVaults } from '@pages/vaults/hooks/vaultsFiltersStorage' import { deriveListKind, isAllocatorVaultOverride } from '@pages/vaults/utils/vaultListFacets' import { useWallet } from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' import { getVaultKey, isV3Vault, type TVaultFlags } from '@shared/hooks/useVaultFilterUtils' import type { TSortDirection } from '@shared/types' -import { toAddress } from '@shared/utils' +import { isZeroAddress, toAddress } from '@shared/utils' import { calculateVaultEstimatedAPY, calculateVaultHistoricalAPY } from '@shared/utils/vaultApy' import { useMemo, useState } from 'react' +import { filterVisiblePortfolioHoldings } from './portfolioVisibility' type THoldingsRow = { key: string @@ -70,70 +74,97 @@ export function usePortfolioModel(): TPortfolioModel { cumulatedValueInV2Vaults, cumulatedValueInV3Vaults, isLoading: isWalletLoading, - getBalance, + getVaultHoldingsUsd, balances } = useWallet() const { isActive, openLoginModal, isUserConnecting, isIdentityLoading } = useWeb3() - const { getPrice, vaults, isLoadingVaultList } = useYearn() + const { vaults, allVaults, isLoadingVaultList } = useYearn() + const showHiddenVaults = usePersistedShowHiddenVaults() const [sortBy, setSortBy] = useState('deposited') const [sortDirection, setSortDirection] = useState('desc') - const vaultLookup = useMemo( - () => - new Map( - Object.values(vaults).flatMap((vault) => { - const entries: [string, TKongVault][] = [[getVaultKey(vault), vault]] - const staking = getVaultStaking(vault) - if (staking?.available && staking.address) { - entries.push([getChainAddressKey(getVaultChainID(vault), staking.address), vault]) - } - return entries - }) - ), - [vaults] - ) + const vaultLookup = useMemo(() => { + const map = new Map() + + Object.values(allVaults).forEach((vault) => { + const canonicalVaultAddress = getCanonicalHoldingsVaultAddress(getVaultAddress(vault)) + const canonicalVault = allVaults[canonicalVaultAddress] ?? vault + const vaultKey = getVaultKey(canonicalVault) + if (!map.has(vaultKey)) { + map.set(vaultKey, canonicalVault) + } + + const staking = getVaultStaking(vault) + if (!isZeroAddress(staking.address)) { + const stakingKey = getChainAddressKey(getVaultChainID(canonicalVault), staking.address) + if (!map.has(stakingKey)) { + map.set(stakingKey, canonicalVault) + } + } + + const directKey = getChainAddressKey(getVaultChainID(canonicalVault), getVaultAddress(vault)) + if (!map.has(directKey)) { + map.set(directKey, canonicalVault) + } + }) + + return map + }, [allVaults]) const holdingsVaults = useMemo(() => { - const allMatched = Object.entries(balances || {}).flatMap(([chainIDKey, perChain]) => { + const result: TKongVault[] = [] + const seen = new Set() + + Object.entries(balances || {}).forEach(([chainIDKey, perChain]) => { const parsedChainID = Number(chainIDKey) const chainID = Number.isFinite(parsedChainID) ? parsedChainID : undefined - return Object.values(perChain || {}) - .filter((token) => token?.balance && token.balance.raw > 0n) - .flatMap((token) => { - const vault = vaultLookup.get(getChainAddressKey(chainID ?? token.chainID, token.address)) - return vault ? [vault] : [] - }) - }) + Object.values(perChain || {}).forEach((token) => { + if (!token?.balance || token.balance.raw <= 0n) { + return + } - const seen = new Set() - return allMatched.filter((vault) => { - const key = getVaultKey(vault) - if (seen.has(key)) return false - seen.add(key) - return true + const tokenChainID = chainID ?? token.chainID + const tokenKey = getChainAddressKey(tokenChainID, token.address) + const vault = vaultLookup.get(tokenKey) + if (!vault) { + return + } + + const vaultKey = getVaultKey(vault) + if (seen.has(vaultKey)) { + return + } + + seen.add(vaultKey) + result.push(vault) + }) }) + + return result }, [balances, vaultLookup]) - const vaultFlags = useMemo( - () => - Object.fromEntries( - holdingsVaults.map((vault) => { - const info = getVaultInfo(vault) - const migration = getVaultMigration(vault) - return [ - getVaultKey(vault), - { - hasHoldings: true, - isMigratable: Boolean(migration?.available), - isRetired: Boolean(info?.isRetired), - isHidden: Boolean(info?.isHidden) - } - ] - }) - ) as Record, - [holdingsVaults] + const visibleHoldingsVaults = useMemo( + () => filterVisiblePortfolioHoldings(holdingsVaults, showHiddenVaults), + [holdingsVaults, showHiddenVaults] ) + const vaultFlags = useMemo(() => { + const flags: Record = {} + + visibleHoldingsVaults.forEach((vault) => { + const key = getVaultKey(vault) + flags[key] = { + hasHoldings: true, + isMigratable: Boolean(getVaultMigration(vault)?.available), + isRetired: Boolean(getVaultInfo(vault)?.isRetired), + isHidden: Boolean(getVaultInfo(vault)?.isHidden), + isNotYearn: isNonYearnErc4626Vault({ vault }) + } + }) + + return flags + }, [visibleHoldingsVaults]) + const isSearchingBalances = (isActive || isUserConnecting) && (isWalletLoading || isUserConnecting || isIdentityLoading) const isHoldingsLoading = (isLoadingVaultList && isActive) || isSearchingBalances @@ -145,19 +176,17 @@ export function usePortfolioModel(): TPortfolioModel { return false } - const info = getVaultInfo(vault) - const migration = getVaultMigration(vault) - const isHidden = Boolean(info?.isHidden) - const isRetired = Boolean(info?.isRetired) - const isMigratable = Boolean(migration?.available) - const isHighlighted = Boolean(info?.isHighlighted) + const isHidden = Boolean(getVaultInfo(vault)?.isHidden) + const isRetired = Boolean(getVaultInfo(vault)?.isRetired) + const isMigratable = Boolean(getVaultMigration(vault)?.available) + const isHighlighted = Boolean(getVaultInfo(vault)?.isHighlighted) return !isHidden && !isRetired && !isMigratable && isHighlighted }), [vaults] ) - const sortedHoldings = useSortVaults(holdingsVaults, sortBy, sortDirection) + const sortedHoldings = useSortVaults(visibleHoldingsVaults, sortBy, sortDirection) const sortedCandidates = useSortVaults(suggestedVaultCandidates, 'tvl', 'desc') const holdingsKeySet = useMemo(() => new Set(sortedHoldings.map((vault) => getVaultKey(vault))), [sortedHoldings]) @@ -231,7 +260,7 @@ export function usePortfolioModel(): TPortfolioModel { () => (vault: (typeof holdingsVaults)[number]): number | null => { const apy = calculateVaultEstimatedAPY(vault) - return apy === 0 ? null : apy + return apy === 0 && !vault.performance?.historical?.net ? null : apy }, [] ) @@ -247,31 +276,9 @@ export function usePortfolioModel(): TPortfolioModel { const getVaultValue = useMemo( () => (vault: (typeof holdingsVaults)[number]): number => { - const chainID = getVaultChainID(vault) - const address = getVaultAddress(vault) - const staking = getVaultStaking(vault) - - const shareBalance = getBalance({ - address, - chainID - }) - const price = getPrice({ - address, - chainID - }) - const baseValue = shareBalance.normalized * price.normalized - - const stakingValue = - staking?.available && staking.address - ? getBalance({ - address: staking.address, - chainID - }).normalized * price.normalized - : 0 - - return baseValue + stakingValue + return getVaultHoldingsUsd(vault) }, - [getBalance, getPrice] + [getVaultHoldingsUsd] ) const blendedMetrics = useMemo(() => { diff --git a/src/components/pages/portfolio/hooks/useVaultWithStakingRewards.ts b/src/components/pages/portfolio/hooks/useVaultWithStakingRewards.ts index 5b600fd5e..0103e7f10 100644 --- a/src/components/pages/portfolio/hooks/useVaultWithStakingRewards.ts +++ b/src/components/pages/portfolio/hooks/useVaultWithStakingRewards.ts @@ -6,6 +6,7 @@ import { type TKongVaultStaking } from '@pages/vaults/domain/kongVaultSelectors' import { useVaultSnapshot } from '@pages/vaults/hooks/useVaultSnapshot' +import { isZeroAddress } from '@shared/utils' type UseVaultWithStakingRewardsReturn = { vault: TKongVault @@ -20,7 +21,7 @@ export function useVaultWithStakingRewards( const baseStaking = getVaultStaking(originalVault) const chainId = getVaultChainID(originalVault) const address = getVaultAddress(originalVault) - const needsFetch = enabled && baseStaking.available + const needsFetch = enabled && !isZeroAddress(baseStaking.address) const { data: snapshot, isLoading } = useVaultSnapshot({ chainId: needsFetch ? chainId : undefined, diff --git a/src/components/pages/portfolio/index.tsx b/src/components/pages/portfolio/index.tsx index 592af2fcc..1c74f4f3f 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -29,7 +29,7 @@ import { useYearn } from '@shared/contexts/useYearn' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' import { IconSpinner } from '@shared/icons/IconSpinner' import type { TSortDirection } from '@shared/types' -import { cl, formatPercent, SUPPORTED_NETWORKS } from '@shared/utils' +import { cl, formatPercent, isZeroAddress, SUPPORTED_NETWORKS } from '@shared/utils' import { formatUSD } from '@shared/utils/format' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { CSSProperties, ReactElement } from 'react' @@ -337,7 +337,7 @@ function ChainStakingRewardsFetcher({ }): null { const { vault, staking, isLoading: isLoadingVault } = useVaultWithStakingRewards(originalVault, isActive) - const stakingAddress = staking.available ? staking.address : undefined + const stakingAddress = !isZeroAddress(staking.address) ? staking.address : undefined const rewardTokens = useMemo( () => (staking.rewards ?? []).map((reward) => ({ @@ -420,7 +420,7 @@ function PortfolioClaimRewardsSection({ isActive, openLoginModal }: TPortfolioCl const { vaults } = useYearn() const trackEvent = usePlausible() const stakingVaults = useMemo( - () => Object.values(vaults).filter((vault) => getVaultStaking(vault).available), + () => Object.values(vaults).filter((vault) => !isZeroAddress(getVaultStaking(vault).address)), [vaults] ) const [selectedChainId, setSelectedChainId] = useState(null) diff --git a/src/components/pages/vaults/[chainID]/[address].tsx b/src/components/pages/vaults/[chainID]/[address].tsx index 637b9764b..62796971b 100644 --- a/src/components/pages/vaults/[chainID]/[address].tsx +++ b/src/components/pages/vaults/[chainID]/[address].tsx @@ -14,13 +14,19 @@ 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 { getVaultView, type TKongVault, type TKongVaultView } from '@pages/vaults/domain/kongVaultSelectors' +import { + getVaultChainID, + getVaultView, + type TKongVault, + type TKongVaultView +} from '@pages/vaults/domain/kongVaultSelectors' import { mergeYBoldSnapshot, mergeYBoldVault, YBOLD_STAKING_ADDRESS, YBOLD_VAULT_ADDRESS } from '@pages/vaults/domain/normalizeVault' +import { isNonYearnErc4626Vault, NON_YEARN_ERC4626_WARNING_MESSAGE } from '@pages/vaults/domain/vaultWarnings' import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' import { useVaultSnapshot } from '@pages/vaults/hooks/useVaultSnapshot' import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' @@ -175,13 +181,19 @@ const buildSnapshotBackedVault = (snapshot: TKongVaultSnapshot): TKongVault => { staking: snapshot.staking ? { address: snapshot.staking.address ?? null, - available: snapshot.staking.available + 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 RetiredVaultAlert({ message, className }: { message: string; className: string }): ReactElement { +function VaultWarningAlert({ message, className }: { message: string; className: string }): ReactElement { const { title, body } = splitFirstSentence(message) return ( @@ -223,7 +235,7 @@ function Index(): ReactElement | null { const chainId = Number(params.chainID) const { getBalance, onRefresh } = useWallet() const { address } = useWeb3() - const { vaults, isLoadingVaultList, enableVaultListFetch } = useYearn() + const { vaults, allVaults, isLoadingVaultList, enableVaultListFetch } = useYearn() const vaultKey = `${params.chainID}-${params.address}` const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) const [mobileDrawerAction, setMobileDrawerAction] = useState(WidgetActionType.Deposit) @@ -326,6 +338,11 @@ function Index(): ReactElement | null { return vaults[resolvedAddress] }, [params.address, vaults]) + const metadataVault = useMemo(() => { + if (!params.address) return undefined + return allVaults[toAddress(params.address)] + }, [allVaults, params.address]) + const hasVaultList = Object.keys(vaults).length > 0 const { @@ -379,7 +396,9 @@ function Index(): ReactElement | null { const vaultViewInput = useMemo(() => { if (!mergedBaseVault) return snapshotBackedVault if (!snapshotBackedVault) return mergedBaseVault - return mergedBaseVault.chainId === snapshotBackedVault.chainId ? mergedBaseVault : snapshotBackedVault + return getVaultChainID(mergedBaseVault) === getVaultChainID(snapshotBackedVault) + ? mergedBaseVault + : snapshotBackedVault }, [mergedBaseVault, snapshotBackedVault]) const isFactoryVault = useMemo(() => { @@ -402,11 +421,16 @@ function Index(): ReactElement | null { const ensoEnabledForVault = useEnsoEnabled({ chainId, vaultAddress: currentVault?.address }) const isLoadingVault = !currentVault && (isLoadingSnapshotVault || (isLoadingVaultList && !isSnapshotNotFound)) + const stakingAddress = !isZeroAddress(currentVault?.staking?.address) + ? toAddress(currentVault?.staking?.address) + : undefined + const disableDepositStaking = shouldDisableStakingForDeposit || !currentVault?.staking?.available const vaultUserData = useVaultUserData({ vaultAddress: toAddress(currentVault?.address ?? '0x'), assetAddress: toAddress(currentVault?.token?.address ?? '0x'), - stakingAddress: currentVault?.staking?.available ? toAddress(currentVault.staking.address) : undefined, + stakingAddress, + stakingSource: currentVault?.staking?.source, chainId, account: address }) @@ -426,11 +450,8 @@ function Index(): ReactElement | null { : 0n const stakingShareBalance = - !!address && - currentVault?.staking.available && - !isZeroAddress(currentVault?.staking.address) && - Number.isInteger(currentVault?.chainID) - ? getBalance({ address: toAddress(currentVault.staking.address), chainID: currentVault.chainID }).raw + !!address && !!stakingAddress && Number.isInteger(currentVault?.chainID) && !!currentVault + ? getBalance({ address: stakingAddress, chainID: currentVault.chainID }).raw : 0n const isMigratable = Boolean(currentVault?.migration?.available) @@ -441,6 +462,12 @@ function Index(): ReactElement | null { if (!isRetired || !currentVault) return null return getRetiredVaultAlertMessage({ vault: currentVault, hasUserFundsInVault }) }, [currentVault, hasUserFundsInVault, isRetired]) + const shouldShowNonYearnVaultAlert = useMemo(() => { + return isNonYearnErc4626Vault({ + vault: metadataVault, + snapshot: mergedSnapshot + }) + }, [metadataVault, mergedSnapshot]) const widgetActions = useMemo(() => { if (isRetired || isMigratable) { return canShowMigrateAction ? [WidgetActionType.Migrate, WidgetActionType.Withdraw] : [WidgetActionType.Withdraw] @@ -1090,7 +1117,11 @@ function Index(): ReactElement | null { /> {isRetired && retiredVaultAlertMessage ? ( - + + ) : null} + + {shouldShowNonYearnVaultAlert ? ( + ) : null} {Number.isInteger(chainId) && ( @@ -1186,7 +1217,7 @@ function Index(): ReactElement | null { vaultAddress={currentVault.address} currentVault={currentVault} gaugeAddress={currentVault.staking.address} - disableDepositStaking={shouldDisableStakingForDeposit} + disableDepositStaking={disableDepositStaking} actions={widgetActions} chainId={chainId} vaultUserData={vaultUserData} @@ -1205,9 +1236,7 @@ function Index(): ReactElement | null { isActive={isWidgetWalletOpen && !isWidgetRewardsOpen} currentVault={currentVault} vaultAddress={toAddress(currentVault.address)} - stakingAddress={ - isZeroAddress(currentVault.staking.address) ? undefined : toAddress(currentVault.staking.address) - } + stakingAddress={stakingAddress} chainId={chainId} vaultUserData={vaultUserData} onSelectZapToken={handleZapTokenSelect} @@ -1217,7 +1246,7 @@ function Index(): ReactElement | null { {shouldShowWidgetRewards ? (
({ address: r.address, @@ -1239,7 +1268,11 @@ function Index(): ReactElement | null {
{isRetired && retiredVaultAlertMessage ? ( - + + ) : null} + + {shouldShowNonYearnVaultAlert ? ( + ) : null} {renderableSections.map((section) => { @@ -1342,7 +1375,7 @@ function Index(): ReactElement | null { vaultAddress={currentVault.address} currentVault={currentVault} gaugeAddress={currentVault.staking.address} - disableDepositStaking={shouldDisableStakingForDeposit} + disableDepositStaking={disableDepositStaking} actions={widgetActions} chainId={chainId} vaultUserData={vaultUserData} diff --git a/src/components/pages/vaults/components/list/VaultsListRow.tsx b/src/components/pages/vaults/components/list/VaultsListRow.tsx index b08f6ba11..d18883157 100755 --- a/src/components/pages/vaults/components/list/VaultsListRow.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRow.tsx @@ -10,7 +10,6 @@ import { getVaultChainID, getVaultName as getVaultDisplayName, getVaultKind, - getVaultStaking, getVaultSymbol, getVaultToken, type TKongVaultInput @@ -24,6 +23,7 @@ import { getProductTypeDescription, HIDDEN_TAG_DESCRIPTION, MIGRATABLE_TAG_DESCRIPTION, + NOT_YEARN_TAG_DESCRIPTION, RETIRED_TAG_DESCRIPTION } from '@pages/vaults/utils/vaultTagCopy' import { useMediaQuery } from '@react-hookz/web' @@ -33,7 +33,7 @@ import { useWeb3 } from '@shared/contexts/useWeb3' import { fetchWithSchema, getFetchQueryKey } from '@shared/hooks/useFetch' import { IconChevron } from '@shared/icons/IconChevron' import { IconEyeOff } from '@shared/icons/IconEyeOff' -import { cl, formatAmount, formatTvlDisplay, getVaultName, isZeroAddress, toAddress } from '@shared/utils' +import { cl, formatAmount, formatTvlDisplay, getVaultName, toAddress } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import { kongVaultSnapshotSchema } from '@shared/utils/schemas/kongVaultSnapshotSchema' import { getNetwork } from '@shared/utils/wagmi' @@ -61,6 +61,7 @@ type TVaultRowFlags = { isMigratable?: boolean isRetired?: boolean isHidden?: boolean + isNotYearn?: boolean } const prefetchedSnapshotEndpoints = new Set() @@ -122,7 +123,6 @@ export function VaultsListRow({ const vaultSymbol = getVaultSymbol(currentVault) const vaultName = getVaultDisplayName(currentVault) const vaultToken = getVaultToken(currentVault) - const staking = getVaultStaking(currentVault) const apr = getVaultAPR(currentVault) const vaultKind = getVaultKind(currentVault) const vaultCategory = getVaultCategory(currentVault) @@ -130,7 +130,7 @@ export function VaultsListRow({ const network = getNetwork(chainID) const chainLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${chainID}/logo-32.png` const { address } = useWeb3() - const { getToken } = useWallet() + const { getVaultHoldingsUsd } = useWallet() const isMobile = useMediaQuery('(max-width: 767px)', { initializeWithValue: false }) ?? false const [isExpandedState, setIsExpandedState] = useState(false) const isExpanded = isExpandedProp ?? isExpandedState @@ -263,21 +263,8 @@ export function VaultsListRow({ if (!showHoldingsChip && mobileSecondaryMetric !== 'holdings') { return 0 } - const vaultToken = getToken({ - chainID, - address: vaultAddress - }) - const vaultValue = vaultToken.value || 0 - - const stakingValue = !isZeroAddress(staking?.address) - ? getToken({ - chainID, - address: staking.address - }).value || 0 - : 0 - - return vaultValue + stakingValue - }, [showHoldingsChip, vaultAddress, chainID, staking?.address, getToken, mobileSecondaryMetric, showHoldingsChip]) + return getVaultHoldingsUsd(currentVault) + }, [showHoldingsChip, mobileSecondaryMetric, currentVault, getVaultHoldingsUsd]) useEffect(() => { if (isExpanded) { @@ -293,7 +280,7 @@ export function VaultsListRow({ > )} diff --git a/src/components/pages/vaults/components/widget/shared/transactionOverlay.helpers.ts b/src/components/pages/vaults/components/widget/shared/transactionOverlay.helpers.ts new file mode 100644 index 000000000..4e52a0196 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/transactionOverlay.helpers.ts @@ -0,0 +1,36 @@ +export type OverlayState = 'idle' | 'confirming' | 'pending' | 'success' | 'error' + +export function shouldAutoContinuePermitSuccess(params: { + overlayState: OverlayState + executedStepIsPermit?: boolean + executedStepAutoContinues: boolean + executedStepCompletesFlow: boolean + currentStepLabel?: string + executedStepLabel?: string + isStepReady: boolean + hasAdvancedFromStep?: string | null + hasAutoContinuedFromStep?: string | null +}): boolean { + const { + overlayState, + executedStepIsPermit, + executedStepAutoContinues, + executedStepCompletesFlow, + currentStepLabel, + executedStepLabel, + isStepReady, + hasAdvancedFromStep, + hasAutoContinuedFromStep + } = params + + if (overlayState !== 'success') return false + if (!executedStepIsPermit) return false + if (!executedStepAutoContinues) return false + if (executedStepCompletesFlow) return false + if (!currentStepLabel || currentStepLabel === executedStepLabel) return false + if (!isStepReady) return false + if (hasAdvancedFromStep === executedStepLabel) return false + if (hasAutoContinuedFromStep === executedStepLabel) return false + + return true +} diff --git a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx index 89a777b5a..4f6c12396 100644 --- a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetailsOverlay.tsx @@ -36,6 +36,7 @@ export const WithdrawDetailsOverlay: FC = ({ }) => { const isFromStaking = withdrawalSource === 'staking' const isUnstake = routeType === 'DIRECT_UNSTAKE' + const isUnstakeAndWithdrawFallback = routeType === 'DIRECT_UNSTAKE_WITHDRAW' const renderReceiveValue = () => { // No input value - just show symbol @@ -85,6 +86,12 @@ export const WithdrawDetailsOverlay: FC = ({ Your {sourceTokenSymbol} will be unstaked. You'll receive {outputTokenSymbol}. + ) : isUnstakeAndWithdrawFallback ? ( + <> + Your {sourceTokenSymbol} staked shares will be + unstaked, then redeemed for {vaultAssetSymbol}. + You'll receive {outputTokenSymbol}. + ) : isFromStaking ? ( <> Your {sourceTokenSymbol} staked shares will be @@ -114,6 +121,11 @@ export const WithdrawDetailsOverlay: FC = ({ Unstaking converts your staked position back to vault shares. Your vault shares continue to earn yield and can be redeemed for the underlying asset anytime. + ) : isUnstakeAndWithdrawFallback ? ( + <> + This withdraw uses two transactions: first your staked shares are unstaked to vault shares, then those + vault shares are redeemed for the underlying asset plus any earned yield. + ) : isFromStaking ? ( <> Your staked shares are first unstaked to vault shares, then redeemed for the underlying asset plus any diff --git a/src/components/pages/vaults/components/widget/withdraw/index.tsx b/src/components/pages/vaults/components/widget/withdraw/index.tsx index 7b9ea66ec..c993f8688 100644 --- a/src/components/pages/vaults/components/widget/withdraw/index.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/index.tsx @@ -8,8 +8,7 @@ import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' import { IconCross } from '@shared/icons/IconCross' import { IconSettings } from '@shared/icons/IconSettings' -import type { TNormalizedBN } from '@shared/types' -import { cl, formatTAmount, toAddress, toNormalizedBN, zeroNormalizedBN } from '@shared/utils' +import { cl, formatTAmount, toAddress, toNormalizedBN } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import { type FC, useCallback, useEffect, useMemo, useState } from 'react' import { formatUnits } from 'viem' @@ -29,6 +28,13 @@ import { useWithdrawFlow } from './useWithdrawFlow' import { useWithdrawNotifications } from './useWithdrawNotifications' import { WithdrawDetails } from './WithdrawDetails' import { WithdrawDetailsOverlay } from './WithdrawDetailsOverlay' +import { + buildWithdrawTransactionStep, + getWithdrawCtaLabel, + getWithdrawTransactionName, + isWithdrawCtaDisabled, + isWithdrawLastStep +} from './withdrawStepHelpers' export const WidgetWithdraw: FC< WithdrawWidgetProps & { hideSettings?: boolean; disableBorderRadius?: boolean; collapseDetails?: boolean } @@ -38,6 +44,7 @@ export const WidgetWithdraw: FC< stakingAddress, chainId, vaultSymbol, + stakingSource, vaultVersion, vaultUserData, handleWithdrawSuccess: onWithdrawSuccess, @@ -60,11 +67,17 @@ export const WidgetWithdraw: FC< const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [withdrawalSource, setWithdrawalSource] = useState(stakingAddress ? null : 'vault') const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) + const [fallbackStep, setFallbackStep] = useState<'unstake' | 'withdraw'>('unstake') + const [redeemSharesOverride, setRedeemSharesOverride] = useState(0n) + const [awaitingPostUnstakeShares, setAwaitingPostUnstakeShares] = useState(false) + const [vaultSharesBeforeUnstake, setVaultSharesBeforeUnstake] = useState(0n) const { assetToken, vaultToken: vault, stakingToken, + stakingWithdrawableAssets, + stakingRedeemableShares, pricePerShare, isLoading: isLoadingVaultData, refetch: refetchVaultUserData @@ -84,7 +97,7 @@ export const WidgetWithdraw: FC< }, [getToken, withdrawToken, destinationChainId, chainId, assetAddress, assetToken]) const hasVaultBalance = (vault?.balance.raw ?? 0n) > 0n - const hasStakingBalance = (stakingToken?.balance.raw ?? 0n) > 0n + const hasStakingBalance = stakingWithdrawableAssets > 0n const hasBothBalances = hasVaultBalance && hasStakingBalance useEffect(() => { @@ -115,12 +128,20 @@ export const WidgetWithdraw: FC< setShowTokenSelector }) - const totalVaultBalance: TNormalizedBN = - withdrawalSource === 'vault' && vault - ? vault.balance - : withdrawalSource === 'staking' && stakingToken - ? stakingToken.balance - : zeroNormalizedBN + useEffect(() => { + if (!showTransactionOverlay) { + setFallbackStep('unstake') + setRedeemSharesOverride(0n) + setAwaitingPostUnstakeShares(false) + setVaultSharesBeforeUnstake(0n) + } + }, [showTransactionOverlay]) + + const sourceVaultSharesRaw = useMemo(() => { + if (withdrawalSource === 'vault') return vault?.balance.raw ?? 0n + if (withdrawalSource === 'staking') return stakingWithdrawableAssets + return 0n + }, [withdrawalSource, vault?.balance.raw, stakingWithdrawableAssets]) const sourceToken = withdrawalSource === 'vault' @@ -131,17 +152,16 @@ export const WidgetWithdraw: FC< const isUnstake = withdrawalSource === 'staking' && toAddress(withdrawToken) === toAddress(vaultAddress) - const sharesDecimals = - withdrawalSource === 'staking' ? (stakingToken?.decimals ?? vault?.decimals ?? 18) : (vault?.decimals ?? 18) + const sharesDecimals = vault?.decimals ?? stakingToken?.decimals ?? 18 const vaultDecimals = vault?.decimals ?? 18 - const totalBalanceInUnderlying: TNormalizedBN = useMemo(() => { - if (pricePerShare === 0n || totalVaultBalance.raw === 0n || !assetToken) { - return zeroNormalizedBN + const totalBalanceInUnderlying = useMemo(() => { + if (pricePerShare === 0n || sourceVaultSharesRaw === 0n || !assetToken) { + return toNormalizedBN(0n, assetToken?.decimals ?? 18) } - const underlyingAmount = (totalVaultBalance.raw * pricePerShare) / 10n ** BigInt(vaultDecimals) + const underlyingAmount = (sourceVaultSharesRaw * pricePerShare) / 10n ** BigInt(vaultDecimals) return toNormalizedBN(underlyingAmount, assetToken.decimals ?? 18) - }, [totalVaultBalance.raw, pricePerShare, vaultDecimals, assetToken]) + }, [sourceVaultSharesRaw, pricePerShare, vaultDecimals, assetToken]) const withdrawInput = useDebouncedInput(assetToken?.decimals ?? 18) const [withdrawAmount, , setWithdrawInput] = withdrawInput @@ -158,7 +178,7 @@ export const WidgetWithdraw: FC< // ============================================================================ const requiredShares = useMemo(() => { if (!withdrawAmount.bn || withdrawAmount.bn === 0n) return 0n - if (isMaxWithdraw && totalVaultBalance.raw > 0n) return totalVaultBalance.raw + if (isMaxWithdraw && sourceVaultSharesRaw > 0n) return sourceVaultSharesRaw if (pricePerShare > 0n) { const numerator = withdrawAmount.bn * 10n ** BigInt(vaultDecimals) @@ -166,24 +186,39 @@ export const WidgetWithdraw: FC< } return 0n - }, [withdrawAmount.bn, isMaxWithdraw, totalVaultBalance.raw, pricePerShare, vaultDecimals]) + }, [withdrawAmount.bn, isMaxWithdraw, sourceVaultSharesRaw, pricePerShare, vaultDecimals]) + + useEffect(() => { + if (!awaitingPostUnstakeShares || fallbackStep !== 'withdraw') return + + const currentVaultShares = vault?.balance.raw ?? 0n + if (currentVaultShares <= vaultSharesBeforeUnstake) return + + setRedeemSharesOverride(currentVaultShares - vaultSharesBeforeUnstake) + setAwaitingPostUnstakeShares(false) + }, [awaitingPostUnstakeShares, fallbackStep, vault?.balance.raw, vaultSharesBeforeUnstake]) - const { routeType, activeFlow } = useWithdrawFlow({ + const blockDirectWithdrawStep = fallbackStep === 'withdraw' && awaitingPostUnstakeShares + + const { routeType, activeFlow, directWithdrawFlow, directUnstakeFlow } = useWithdrawFlow({ withdrawToken, assetAddress, vaultAddress, sourceToken, stakingAddress, + stakingSource, amount: withdrawAmount.debouncedBn, currentAmount: withdrawAmount.bn, requiredShares, - maxShares: totalVaultBalance.raw, + maxShares: sourceVaultSharesRaw, + redeemSharesOverride, isMaxWithdraw, + unstakeMaxRedeemShares: withdrawalSource === 'staking' ? stakingRedeemableShares : 0n, + allowDirectWithdrawStep: !blockDirectWithdrawStep, account, chainId, destinationChainId, outputChainId: outputToken?.chainID ?? chainId, - assetDecimals: assetToken?.decimals ?? 18, vaultDecimals: vault?.decimals ?? 18, outputDecimals: outputToken?.decimals ?? 18, pricePerShare, @@ -193,33 +228,38 @@ export const WidgetWithdraw: FC< isDebouncing: withdrawAmount.isDebouncing, useErc4626: usesErc4626 }) + const effectiveDirectWithdrawPrepare = blockDirectWithdrawStep + ? undefined + : directWithdrawFlow.actions.prepareWithdraw const isCrossChain = destinationChainId !== chainId - const { approveNotificationParams, withdrawNotificationParams } = useWithdrawNotifications({ - vault, - outputToken, - stakingToken, - sourceToken, - assetAddress, - withdrawToken, - account, - chainId, - destinationChainId, - withdrawAmount: withdrawAmount.debouncedBn, - requiredShares, - expectedOut: activeFlow.periphery.expectedOut, - routeType, - routerAddress: activeFlow.periphery.routerAddress, - isCrossChain, - withdrawalSource: withdrawalSource || 'vault' - }) + const { approveNotificationParams, unstakeNotificationParams, withdrawNotificationParams } = useWithdrawNotifications( + { + vault, + outputToken, + stakingToken, + sourceToken, + assetAddress, + withdrawToken, + account, + chainId, + destinationChainId, + withdrawAmount: withdrawAmount.debouncedBn, + requiredShares, + expectedOut: activeFlow.periphery.expectedOut, + routeType, + routerAddress: activeFlow.periphery.routerAddress, + isCrossChain, + withdrawalSource: withdrawalSource || 'vault' + } + ) const withdrawError = useWithdrawError({ amount: withdrawAmount.bn, debouncedAmount: withdrawAmount.debouncedBn, isDebouncing: withdrawAmount.isDebouncing, requiredShares, - totalBalance: totalVaultBalance.raw, + totalBalance: sourceVaultSharesRaw, account, isLoadingRoute: activeFlow.periphery.isLoadingRoute, flowError: activeFlow.periphery.error, @@ -227,6 +267,7 @@ export const WidgetWithdraw: FC< hasBothBalances: !!hasBothBalances, withdrawalSource }) + const isFetchingQuote = routeType === 'ENSO' && Boolean(activeFlow.periphery.isLoadingRoute) const actionLabel = isUnstake ? 'You will unstake' @@ -234,14 +275,7 @@ export const WidgetWithdraw: FC< ? 'You will unstake and redeem' : 'You will redeem' - const transactionName = - routeType === 'DIRECT_WITHDRAW' - ? 'Withdraw' - : routeType === 'DIRECT_UNSTAKE' - ? 'Unstake' - : activeFlow.periphery.isLoadingRoute - ? 'Fetching quote' - : 'Withdraw' + const transactionName = getWithdrawTransactionName(routeType, isFetchingQuote) const showApprove = routeType === 'ENSO' @@ -272,7 +306,7 @@ export const WidgetWithdraw: FC< address: outputToken?.address || '', chainId: outputToken?.chainID || chainId, expectedAmount: getExpectedAmount(), - isLoading: isUnstake ? false : activeFlow.periphery.isLoadingRoute + isLoading: isUnstake ? false : isFetchingQuote } }, [ withdrawToken, @@ -281,7 +315,7 @@ export const WidgetWithdraw: FC< requiredShares, vault?.decimals, activeFlow.periphery.expectedOut, - activeFlow.periphery.isLoadingRoute, + isFetchingQuote, outputToken?.symbol, outputToken?.address, outputToken?.chainID, @@ -290,58 +324,100 @@ export const WidgetWithdraw: FC< ]) const formattedWithdrawAmount = formatTAmount({ value: withdrawAmount.bn, decimals: assetToken?.decimals ?? 18 }) + const formattedRequiredShares = formatTAmount({ value: requiredShares, decimals: sharesDecimals }) const needsApproval = showApprove && !activeFlow.periphery.isAllowanceSufficient const approvalToken = withdrawalSource === 'staking' ? stakingToken : vault - const formattedApprovalAmount = formatTAmount({ value: requiredShares, decimals: approvalToken?.decimals ?? 18 }) - - const currentStep: TransactionStep | undefined = useMemo(() => { - if (needsApproval && activeFlow.actions.prepareApprove) { - return { - prepare: activeFlow.actions.prepareApprove, - label: 'Approve', - confirmMessage: `Approving ${formattedApprovalAmount} ${approvalToken?.symbol || ''}`, - successTitle: 'Approval successful', - successMessage: `Approved ${formattedApprovalAmount} ${approvalToken?.symbol || ''}.\nReady to withdraw.`, - notification: approveNotificationParams - } - } + const formattedApprovalAmount = formatTAmount({ value: requiredShares, decimals: sharesDecimals }) + + const currentStep: TransactionStep | undefined = useMemo( + () => + buildWithdrawTransactionStep({ + needsApproval, + approvePrepare: activeFlow.actions.prepareApprove, + activeWithdrawPrepare: activeFlow.actions.prepareWithdraw, + directUnstakePrepare: directUnstakeFlow.actions.prepareWithdraw, + directWithdrawPrepare: effectiveDirectWithdrawPrepare, + fallbackStep, + routeType, + isCrossChain, + formattedApprovalAmount, + approvalTokenSymbol: approvalToken?.symbol, + formattedRequiredShares, + formattedWithdrawAmount, + assetTokenSymbol: assetToken?.symbol, + vaultSymbol: vault?.symbol, + stakingTokenSymbol: stakingToken?.symbol, + approveNotificationParams, + unstakeNotificationParams, + withdrawNotificationParams + }), + [ + needsApproval, + activeFlow.actions.prepareApprove, + activeFlow.actions.prepareWithdraw, + directUnstakeFlow.actions.prepareWithdraw, + effectiveDirectWithdrawPrepare, + fallbackStep, + routeType, + isCrossChain, + formattedApprovalAmount, + approvalToken?.symbol, + formattedRequiredShares, + formattedWithdrawAmount, + assetToken?.symbol, + vault?.symbol, + stakingToken?.symbol, + approveNotificationParams, + unstakeNotificationParams, + withdrawNotificationParams + ] + ) + + const isLastStep = useMemo( + () => + isWithdrawLastStep({ + currentStep, + needsApproval, + routeType + }), + [currentStep, needsApproval, routeType] + ) + + const handleTransactionStepSuccess = useCallback( + (label: string) => { + if (routeType === 'DIRECT_UNSTAKE_WITHDRAW' && label === 'Unstake') { + setFallbackStep('withdraw') + setWithdrawalSource('vault') + setAwaitingPostUnstakeShares(isMaxWithdraw) + setRedeemSharesOverride(isMaxWithdraw ? 0n : requiredShares) - const withdrawLabel = routeType === 'DIRECT_UNSTAKE' ? 'Unstake' : 'Withdraw' - - // Cross-chain transactions show different success messages - if (isCrossChain) { - return { - prepare: activeFlow.actions.prepareWithdraw, - label: withdrawLabel, - confirmMessage: `${routeType === 'DIRECT_UNSTAKE' ? 'Unstaking' : 'Withdrawing'} ${formattedWithdrawAmount} ${assetToken?.symbol || ''}`, - successTitle: 'Transaction Submitted', - successMessage: `Your cross-chain ${withdrawLabel.toLowerCase()} has been submitted.\nIt may take a few minutes to complete on the destination chain.`, - notification: withdrawNotificationParams + const tokensToRefresh = [{ address: vaultAddress, chainID: chainId }] + if (stakingAddress) { + tokensToRefresh.push({ address: stakingAddress, chainID: chainId }) + } + void refreshWalletBalances(tokensToRefresh) + refetchVaultUserData() } - } + }, + [ + routeType, + isMaxWithdraw, + requiredShares, + vaultAddress, + chainId, + stakingAddress, + refreshWalletBalances, + refetchVaultUserData + ] + ) - return { - prepare: activeFlow.actions.prepareWithdraw, - label: withdrawLabel, - confirmMessage: `${routeType === 'DIRECT_UNSTAKE' ? 'Unstaking' : 'Withdrawing'} ${formattedWithdrawAmount} ${assetToken?.symbol || ''}`, - successTitle: `${withdrawLabel} successful!`, - successMessage: `You have ${routeType === 'DIRECT_UNSTAKE' ? 'unstaked' : 'withdrawn'} ${formattedWithdrawAmount} ${assetToken?.symbol || ''}.`, - notification: withdrawNotificationParams + const handleOpenTransactionOverlay = useCallback(() => { + if (routeType === 'DIRECT_UNSTAKE_WITHDRAW' && fallbackStep === 'unstake' && isMaxWithdraw) { + setVaultSharesBeforeUnstake(vault?.balance.raw ?? 0n) } - }, [ - needsApproval, - activeFlow.actions.prepareApprove, - activeFlow.actions.prepareWithdraw, - formattedWithdrawAmount, - formattedApprovalAmount, - assetToken?.symbol, - approvalToken?.symbol, - routeType, - approveNotificationParams, - withdrawNotificationParams, - isCrossChain - ]) + setShowTransactionOverlay(true) + }, [routeType, fallbackStep, isMaxWithdraw, vault?.balance.raw]) const handleWithdrawSuccess = useCallback(() => { const sharesToWithdraw = formatUnits(withdrawAmount.bn, assetToken?.decimals ?? 18) @@ -420,7 +496,7 @@ export const WidgetWithdraw: FC< actionLabel={actionLabel} requiredShares={requiredShares} sharesDecimals={sharesDecimals} - isLoadingQuote={activeFlow.periphery.isLoadingRoute} + isLoadingQuote={isFetchingQuote} expectedOut={activeFlow.periphery.expectedOut} outputDecimals={outputToken?.decimals ?? 18} outputSymbol={outputToken?.symbol} @@ -461,28 +537,28 @@ export const WidgetWithdraw: FC< ) : ( )}
@@ -607,9 +683,10 @@ export const WidgetWithdraw: FC< isOpen={showTransactionOverlay} onClose={() => setShowTransactionOverlay(false)} step={currentStep} - isLastStep={!needsApproval} + isLastStep={isLastStep} autoContinueToNextStep - autoContinueStepLabels={['Approve', 'Sign Permit']} + autoContinueStepLabels={['Approve', 'Sign Permit', 'Unstake']} + onStepSuccess={handleTransactionStepSuccess} onAllComplete={handleWithdrawSuccess} /> @@ -631,7 +708,7 @@ export const WidgetWithdraw: FC< withdrawalSource={withdrawalSource} routeType={routeType} isZap={routeType === 'ENSO' && selectedToken !== assetAddress} - isLoadingQuote={activeFlow.periphery.isLoadingRoute} + isLoadingQuote={isFetchingQuote} /> {/* Full-screen Token Selector Overlay */} diff --git a/src/components/pages/vaults/components/widget/withdraw/types.ts b/src/components/pages/vaults/components/widget/withdraw/types.ts index bf9e053b2..8b82736b9 100644 --- a/src/components/pages/vaults/components/widget/withdraw/types.ts +++ b/src/components/pages/vaults/components/widget/withdraw/types.ts @@ -1,6 +1,6 @@ import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' -export type WithdrawRouteType = 'DIRECT_WITHDRAW' | 'DIRECT_UNSTAKE' | 'ENSO' +export type WithdrawRouteType = 'DIRECT_WITHDRAW' | 'DIRECT_UNSTAKE' | 'DIRECT_UNSTAKE_WITHDRAW' | 'ENSO' export type WithdrawalSource = 'vault' | 'staking' | null @@ -10,6 +10,7 @@ export interface WithdrawWidgetProps { stakingAddress?: `0x${string}` chainId: number vaultSymbol: string + stakingSource?: string vaultVersion?: string isVaultRetired?: boolean vaultUserData: VaultUserData diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts index 2c1ba69f9..733818f94 100644 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts +++ b/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts @@ -14,19 +14,21 @@ interface UseWithdrawFlowProps { vaultAddress: Address sourceToken: Address stakingAddress?: Address + stakingSource?: string // Amounts amount: bigint currentAmount: bigint requiredShares: bigint maxShares: bigint + redeemSharesOverride?: bigint isMaxWithdraw: boolean + unstakeMaxRedeemShares: bigint + allowDirectWithdrawStep?: boolean // Account & chain account?: Address chainId: number destinationChainId: number outputChainId: number - // Decimals - assetDecimals: number vaultDecimals: number outputDecimals: number // Price per share @@ -42,6 +44,8 @@ interface UseWithdrawFlowProps { export interface WithdrawFlowResult { routeType: WithdrawRouteType activeFlow: UseWidgetWithdrawFlowReturn + directWithdrawFlow: UseWidgetWithdrawFlowReturn + directUnstakeFlow: UseWidgetWithdrawFlowReturn } export const useWithdrawFlow = ({ @@ -50,16 +54,19 @@ export const useWithdrawFlow = ({ vaultAddress, sourceToken, stakingAddress, + stakingSource, amount, currentAmount, requiredShares, maxShares, + redeemSharesOverride, isMaxWithdraw, + unstakeMaxRedeemShares, + allowDirectWithdrawStep = true, account, chainId, destinationChainId, outputChainId, - assetDecimals, vaultDecimals, outputDecimals, pricePerShare, @@ -83,26 +90,31 @@ export const useWithdrawFlow = ({ // Direct withdraw flow (vault → asset) const directWithdraw = useDirectWithdraw({ vaultAddress, - assetAddress, amount, maxShares, + redeemSharesOverride, redeemAll: isMaxWithdraw, pricePerShare, account, chainId, - decimals: assetDecimals, vaultDecimals, - enabled: routeType === 'DIRECT_WITHDRAW' && amount > 0n, + enabled: + allowDirectWithdrawStep && + (routeType === 'DIRECT_WITHDRAW' || routeType === 'DIRECT_UNSTAKE_WITHDRAW') && + amount > 0n, useErc4626 }) // Direct unstake flow (staking → vault) const directUnstake = useDirectUnstake({ stakingAddress, + stakingSource, amount: requiredShares, + redeemAll: isMaxWithdraw, + maxRedeemShares: unstakeMaxRedeemShares, account, chainId, - enabled: routeType === 'DIRECT_UNSTAKE' && currentAmount > 0n + enabled: (routeType === 'DIRECT_UNSTAKE' || routeType === 'DIRECT_UNSTAKE_WITHDRAW') && currentAmount > 0n }) // Enso flow (zaps, cross-chain, etc.) @@ -124,11 +136,34 @@ export const useWithdrawFlow = ({ const activeFlow = useMemo((): UseWidgetWithdrawFlowReturn => { if (routeType === 'DIRECT_WITHDRAW') return directWithdraw if (routeType === 'DIRECT_UNSTAKE') return directUnstake + if (routeType === 'DIRECT_UNSTAKE_WITHDRAW') { + return { + actions: { + prepareWithdraw: directUnstake.actions.prepareWithdraw + }, + periphery: { + prepareApproveEnabled: false, + prepareWithdrawEnabled: directUnstake.periphery.prepareWithdrawEnabled, + isAllowanceSufficient: true, + allowance: directWithdraw.periphery.allowance, + expectedOut: directWithdraw.periphery.expectedOut, + isLoadingRoute: + directUnstake.actions.prepareWithdraw.isLoading || + directUnstake.actions.prepareWithdraw.isFetching || + directWithdraw.actions.prepareWithdraw.isLoading || + directWithdraw.actions.prepareWithdraw.isFetching, + isCrossChain: false, + error: undefined + } + } + } return ensoFlow }, [routeType, directWithdraw, directUnstake, ensoFlow]) return { routeType, - activeFlow + activeFlow, + directWithdrawFlow: directWithdraw, + directUnstakeFlow: directUnstake } } diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawNotifications.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawNotifications.ts index b7fee09f1..112898886 100644 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawNotifications.ts +++ b/src/components/pages/vaults/components/widget/withdraw/useWithdrawNotifications.ts @@ -32,6 +32,7 @@ interface UseWithdrawNotificationsProps { interface WithdrawNotificationsResult { approveNotificationParams?: TCreateNotificationParams + unstakeNotificationParams?: TCreateNotificationParams withdrawNotificationParams?: TCreateNotificationParams } @@ -56,20 +57,21 @@ export const useWithdrawNotifications = ({ const isZap = toAddress(withdrawToken) !== toAddress(assetAddress) const isUnstakeAndWithdraw = withdrawalSource === 'staking' && toAddress(withdrawToken) === toAddress(assetAddress) && !isZap + const shareDecimals = vault?.decimals ?? stakingToken?.decimals ?? 18 // Determine source token info based on withdrawal source const sourceTokenInfo = useMemo(() => { if (withdrawalSource === 'staking' && stakingToken) { return { symbol: stakingToken.symbol || '', - decimals: stakingToken.decimals ?? 18 + decimals: shareDecimals } } return { symbol: vault?.symbol || '', - decimals: vault?.decimals ?? 18 + decimals: shareDecimals } - }, [withdrawalSource, stakingToken, vault]) + }, [withdrawalSource, stakingToken, vault, shareDecimals]) // Approve notification: approving source token (vault/staking shares) to Enso router const approveNotificationParams = useMemo((): TCreateNotificationParams | undefined => { @@ -86,10 +88,36 @@ export const useWithdrawNotifications = ({ } }, [vault, account, routeType, routerAddress, requiredShares, sourceTokenInfo, sourceToken, chainId]) - // Withdraw notification: swapping shares for output token + // Unstake notification: first step of the fallback flow + const unstakeNotificationParams = useMemo((): TCreateNotificationParams | undefined => { + if (!vault || !account || routeType !== 'DIRECT_UNSTAKE_WITHDRAW' || withdrawAmount === 0n) return undefined + + return { + type: 'unstake', + amount: formatTAmount({ value: requiredShares, decimals: sourceTokenInfo.decimals }), + fromAddress: toAddress(sourceToken), + fromSymbol: sourceTokenInfo.symbol, + fromChainId: chainId, + toAddress: toAddress(vault.address), + toSymbol: vault.symbol || '' + } + }, [vault, account, routeType, withdrawAmount, requiredShares, sourceTokenInfo, sourceToken, chainId]) + + // Withdraw notification: final withdrawal step const withdrawNotificationParams = useMemo((): TCreateNotificationParams | undefined => { if (!vault || !outputToken || !account || withdrawAmount === 0n) return undefined + const withdrawFromTokenInfo = + routeType === 'DIRECT_UNSTAKE_WITHDRAW' + ? { + symbol: vault.symbol || '', + decimals: vault.decimals ?? 18 + } + : sourceTokenInfo + + const withdrawFromAddress = + routeType === 'DIRECT_UNSTAKE_WITHDRAW' ? toAddress(vault.address) : toAddress(sourceToken) + let notificationType: 'withdraw' | 'withdraw zap' | 'crosschain withdraw zap' | 'unstake' | 'unstake and withdraw' = 'withdraw' if (routeType === 'ENSO') { @@ -104,13 +132,15 @@ export const useWithdrawNotifications = ({ } } else if (routeType === 'DIRECT_UNSTAKE') { notificationType = 'unstake' + } else if (routeType === 'DIRECT_UNSTAKE_WITHDRAW') { + notificationType = 'withdraw' } return { type: notificationType, - amount: formatTAmount({ value: requiredShares, decimals: sourceTokenInfo.decimals }), - fromAddress: toAddress(sourceToken), - fromSymbol: sourceTokenInfo.symbol, + amount: formatTAmount({ value: requiredShares, decimals: withdrawFromTokenInfo.decimals }), + fromAddress: withdrawFromAddress, + fromSymbol: withdrawFromTokenInfo.symbol, fromChainId: chainId, toAddress: toAddress(withdrawToken), toSymbol: outputToken.symbol || '', @@ -137,6 +167,7 @@ export const useWithdrawNotifications = ({ return { approveNotificationParams, + unstakeNotificationParams, withdrawNotificationParams } } diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts index a46310c74..00d43ce83 100644 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts +++ b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts @@ -33,12 +33,20 @@ export const resolveWithdrawRouteType = ({ return 'DIRECT_UNSTAKE' } + const isUnstakeAndWithdrawFallback = + withdrawalSource === 'staking' && toAddress(withdrawToken) === toAddress(assetAddress) && chainId === outputChainId + + // Case 2: Staked shares → asset fallback (unstake then withdraw) + if (isUnstakeAndWithdrawFallback) { + return 'DIRECT_UNSTAKE_WITHDRAW' + } + // When Enso disabled, always use direct withdraw if (!ensoEnabled) { return 'DIRECT_WITHDRAW' } - // Case 2: Direct withdraw (vault → asset, same token, from vault source) + // Case 3: Direct withdraw (vault → asset, same token, from vault source) if ( toAddress(withdrawToken) === toAddress(assetAddress) && withdrawalSource === 'vault' && @@ -47,7 +55,7 @@ export const resolveWithdrawRouteType = ({ return 'DIRECT_WITHDRAW' } - // Case 3: Everything else uses Enso + // Case 4: Everything else uses Enso return 'ENSO' } @@ -55,6 +63,7 @@ export const resolveWithdrawRouteType = ({ * Determines the routing type for a withdraw transaction. * - DIRECT_WITHDRAW: vault → asset (simple redeem) * - DIRECT_UNSTAKE: staking → vault (unstake) + * - DIRECT_UNSTAKE_WITHDRAW: staking → vault → asset (two-step fallback) * - ENSO: all other cases (zaps, cross-chain, etc.) */ export const useWithdrawRoute = ({ diff --git a/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts b/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts new file mode 100644 index 000000000..efd0d051a --- /dev/null +++ b/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest' +import { + buildWithdrawTransactionStep, + getWithdrawCtaLabel, + getWithdrawTransactionName, + isWithdrawCtaDisabled, + isWithdrawLastStep +} from './withdrawStepHelpers' + +const mockPrepare = { isSuccess: true, data: { request: {} } } as any + +describe('withdrawStepHelpers', () => { + it('returns transaction names for route types', () => { + expect(getWithdrawTransactionName('DIRECT_WITHDRAW', false)).toBe('Withdraw') + expect(getWithdrawTransactionName('DIRECT_UNSTAKE', false)).toBe('Unstake') + expect(getWithdrawTransactionName('DIRECT_UNSTAKE_WITHDRAW', false)).toBe('Unstake & Withdraw') + expect(getWithdrawTransactionName('ENSO', true)).toBe('Fetching quote') + }) + + it('builds approval step when approval is required', () => { + const step = buildWithdrawTransactionStep({ + needsApproval: true, + approvePrepare: mockPrepare, + activeWithdrawPrepare: mockPrepare, + fallbackStep: 'unstake', + routeType: 'ENSO', + isCrossChain: false, + formattedApprovalAmount: '1.23', + formattedRequiredShares: '1.23', + formattedWithdrawAmount: '1.23', + approvalTokenSymbol: 'yvUSDC', + withdrawNotificationParams: undefined + }) + + expect(step?.label).toBe('Approve') + expect(step?.confirmMessage).toContain('Approving 1.23 yvUSDC') + }) + + it('builds unstake and withdraw fallback steps', () => { + const unstakeStep = buildWithdrawTransactionStep({ + needsApproval: false, + activeWithdrawPrepare: mockPrepare, + directUnstakePrepare: mockPrepare, + directWithdrawPrepare: mockPrepare, + fallbackStep: 'unstake', + routeType: 'DIRECT_UNSTAKE_WITHDRAW', + isCrossChain: false, + formattedApprovalAmount: '1.00', + formattedRequiredShares: '2.00', + formattedWithdrawAmount: '3.00', + stakingTokenSymbol: 'st-yvUSDC', + assetTokenSymbol: 'USDC', + withdrawNotificationParams: undefined + }) + + const withdrawStep = buildWithdrawTransactionStep({ + needsApproval: false, + activeWithdrawPrepare: mockPrepare, + directUnstakePrepare: mockPrepare, + directWithdrawPrepare: mockPrepare, + fallbackStep: 'withdraw', + routeType: 'DIRECT_UNSTAKE_WITHDRAW', + isCrossChain: false, + formattedApprovalAmount: '1.00', + formattedRequiredShares: '2.00', + formattedWithdrawAmount: '3.00', + stakingTokenSymbol: 'st-yvUSDC', + assetTokenSymbol: 'USDC', + withdrawNotificationParams: undefined + }) + + expect(unstakeStep?.label).toBe('Unstake') + expect(withdrawStep?.label).toBe('Withdraw') + }) + + it('builds cross-chain success messaging for regular routes', () => { + const step = buildWithdrawTransactionStep({ + needsApproval: false, + activeWithdrawPrepare: mockPrepare, + fallbackStep: 'unstake', + routeType: 'ENSO', + isCrossChain: true, + formattedApprovalAmount: '1.00', + formattedRequiredShares: '2.00', + formattedWithdrawAmount: '3.00', + assetTokenSymbol: 'USDC', + withdrawNotificationParams: undefined + }) + + expect(step?.successTitle).toBe('Transaction Submitted') + }) + + it('computes last step state correctly', () => { + expect( + isWithdrawLastStep({ + currentStep: undefined, + needsApproval: false, + routeType: 'ENSO' + }) + ).toBe(true) + + expect( + isWithdrawLastStep({ + currentStep: { + prepare: mockPrepare, + label: 'Approve', + confirmMessage: '', + successTitle: '', + successMessage: '' + }, + needsApproval: true, + routeType: 'ENSO' + }) + ).toBe(false) + + expect( + isWithdrawLastStep({ + currentStep: { + prepare: mockPrepare, + label: 'Unstake', + confirmMessage: '', + successTitle: '', + successMessage: '' + }, + needsApproval: false, + routeType: 'DIRECT_UNSTAKE_WITHDRAW' + }) + ).toBe(false) + + expect( + isWithdrawLastStep({ + currentStep: { + prepare: mockPrepare, + label: 'Withdraw', + confirmMessage: '', + successTitle: '', + successMessage: '' + }, + needsApproval: false, + routeType: 'DIRECT_UNSTAKE_WITHDRAW' + }) + ).toBe(true) + }) + + it('computes CTA disabled state and label', () => { + expect( + isWithdrawCtaDisabled({ + hasError: false, + withdrawAmountRaw: 1n, + isFetchingQuote: false, + isDebouncing: false, + showApprove: true, + isAllowanceSufficient: false, + prepareApproveEnabled: false, + prepareWithdrawEnabled: true + }) + ).toBe(true) + + expect( + isWithdrawCtaDisabled({ + hasError: false, + withdrawAmountRaw: 1n, + isFetchingQuote: false, + isDebouncing: false, + showApprove: false, + isAllowanceSufficient: true, + prepareApproveEnabled: false, + prepareWithdrawEnabled: true + }) + ).toBe(false) + + expect( + getWithdrawCtaLabel({ + isFetchingQuote: false, + showApprove: true, + isAllowanceSufficient: false, + transactionName: 'Withdraw' + }) + ).toBe('Approve & Withdraw') + }) +}) diff --git a/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.ts b/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.ts new file mode 100644 index 000000000..2423c350d --- /dev/null +++ b/src/components/pages/vaults/components/widget/withdraw/withdrawStepHelpers.ts @@ -0,0 +1,203 @@ +import type { TCreateNotificationParams } from '@shared/types/notifications' +import type { TransactionStep } from '../shared/TransactionOverlay' +import type { WithdrawRouteType } from './types' + +type TBuildWithdrawTransactionStepArgs = { + needsApproval: boolean + approvePrepare?: TransactionStep['prepare'] + activeWithdrawPrepare?: TransactionStep['prepare'] + directUnstakePrepare?: TransactionStep['prepare'] + directWithdrawPrepare?: TransactionStep['prepare'] + fallbackStep: 'unstake' | 'withdraw' + routeType: WithdrawRouteType + isCrossChain: boolean + formattedApprovalAmount: string + approvalTokenSymbol?: string + formattedRequiredShares: string + formattedWithdrawAmount: string + assetTokenSymbol?: string + vaultSymbol?: string + stakingTokenSymbol?: string + approveNotificationParams?: TCreateNotificationParams + unstakeNotificationParams?: TCreateNotificationParams + withdrawNotificationParams?: TCreateNotificationParams +} + +type TWithdrawCtaStateArgs = { + hasError: boolean + withdrawAmountRaw: bigint + isFetchingQuote: boolean + isDebouncing: boolean + showApprove: boolean + isAllowanceSufficient: boolean + prepareApproveEnabled: boolean + prepareWithdrawEnabled: boolean +} + +export function getWithdrawTransactionName(routeType: WithdrawRouteType, isFetchingQuote: boolean): string { + if (routeType === 'DIRECT_WITHDRAW') { + return 'Withdraw' + } + if (routeType === 'DIRECT_UNSTAKE') { + return 'Unstake' + } + if (routeType === 'DIRECT_UNSTAKE_WITHDRAW') { + return 'Unstake & Withdraw' + } + return isFetchingQuote ? 'Fetching quote' : 'Withdraw' +} + +export function buildWithdrawTransactionStep({ + needsApproval, + approvePrepare, + activeWithdrawPrepare, + directUnstakePrepare, + directWithdrawPrepare, + fallbackStep, + routeType, + isCrossChain, + formattedApprovalAmount, + approvalTokenSymbol, + formattedRequiredShares, + formattedWithdrawAmount, + assetTokenSymbol, + vaultSymbol, + stakingTokenSymbol, + approveNotificationParams, + unstakeNotificationParams, + withdrawNotificationParams +}: TBuildWithdrawTransactionStepArgs): TransactionStep | undefined { + if (needsApproval && approvePrepare) { + return { + prepare: approvePrepare, + label: 'Approve', + confirmMessage: `Approving ${formattedApprovalAmount} ${approvalTokenSymbol || ''}`, + successTitle: 'Approval successful', + successMessage: `Approved ${formattedApprovalAmount} ${approvalTokenSymbol || ''}.\nReady to withdraw.`, + completesFlow: false, + notification: approveNotificationParams + } + } + + if (routeType === 'DIRECT_UNSTAKE_WITHDRAW') { + const unstakeSymbol = stakingTokenSymbol || vaultSymbol || 'shares' + + if (fallbackStep === 'unstake' && directUnstakePrepare) { + return { + prepare: directUnstakePrepare, + label: 'Unstake', + confirmMessage: `Unstaking ${formattedRequiredShares} ${unstakeSymbol}`, + successTitle: 'Unstake successful!', + successMessage: `You have unstaked ${formattedRequiredShares} ${unstakeSymbol}.\nPreparing your withdraw.`, + completesFlow: false, + notification: unstakeNotificationParams + } + } + + if (!directWithdrawPrepare) { + return undefined + } + + return { + prepare: directWithdrawPrepare, + label: 'Withdraw', + confirmMessage: `Withdrawing ${formattedWithdrawAmount} ${assetTokenSymbol || ''}`, + successTitle: 'Withdraw successful!', + successMessage: `You have withdrawn ${formattedWithdrawAmount} ${assetTokenSymbol || ''}.`, + completesFlow: true, + notification: withdrawNotificationParams + } + } + + if (!activeWithdrawPrepare) { + return undefined + } + + const withdrawLabel = routeType === 'DIRECT_UNSTAKE' ? 'Unstake' : 'Withdraw' + const actionVerb = routeType === 'DIRECT_UNSTAKE' ? 'Unstaking' : 'Withdrawing' + + if (isCrossChain) { + return { + prepare: activeWithdrawPrepare, + label: withdrawLabel, + confirmMessage: `${actionVerb} ${formattedWithdrawAmount} ${assetTokenSymbol || ''}`, + successTitle: 'Transaction Submitted', + successMessage: `Your cross-chain ${withdrawLabel.toLowerCase()} has been submitted.\nIt may take a few minutes to complete on the destination chain.`, + completesFlow: true, + notification: withdrawNotificationParams + } + } + + const successAction = routeType === 'DIRECT_UNSTAKE' ? 'unstaked' : 'withdrawn' + return { + prepare: activeWithdrawPrepare, + label: withdrawLabel, + confirmMessage: `${actionVerb} ${formattedWithdrawAmount} ${assetTokenSymbol || ''}`, + successTitle: `${withdrawLabel} successful!`, + successMessage: `You have ${successAction} ${formattedWithdrawAmount} ${assetTokenSymbol || ''}.`, + completesFlow: true, + notification: withdrawNotificationParams + } +} + +export function isWithdrawLastStep({ + currentStep, + needsApproval, + routeType +}: { + currentStep?: TransactionStep + needsApproval: boolean + routeType: WithdrawRouteType +}): boolean { + if (!currentStep) return true + if (needsApproval) return false + if (routeType === 'DIRECT_UNSTAKE_WITHDRAW') { + return currentStep.label === 'Withdraw' + } + return true +} + +export function isWithdrawCtaDisabled({ + hasError, + withdrawAmountRaw, + isFetchingQuote, + isDebouncing, + showApprove, + isAllowanceSufficient, + prepareApproveEnabled, + prepareWithdrawEnabled +}: TWithdrawCtaStateArgs): boolean { + if (hasError || withdrawAmountRaw === 0n || isFetchingQuote || isDebouncing) { + return true + } + + if (showApprove && !isAllowanceSufficient && !prepareApproveEnabled) { + return true + } + + if ((!showApprove || isAllowanceSufficient) && !prepareWithdrawEnabled) { + return true + } + + return false +} + +export function getWithdrawCtaLabel({ + isFetchingQuote, + showApprove, + isAllowanceSufficient, + transactionName +}: { + isFetchingQuote: boolean + showApprove: boolean + isAllowanceSufficient: boolean + transactionName: string +}): string { + if (isFetchingQuote) { + return 'Fetching quote' + } + if (showApprove && !isAllowanceSufficient) { + return `Approve & ${transactionName}` + } + return transactionName +} diff --git a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts b/src/components/pages/vaults/domain/kongVaultSelectors.test.ts index cfc1d246c..13b577c09 100644 --- a/src/components/pages/vaults/domain/kongVaultSelectors.test.ts +++ b/src/components/pages/vaults/domain/kongVaultSelectors.test.ts @@ -1,138 +1,117 @@ -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' -import { getVaultAPR } from '@pages/vaults/domain/kongVaultSelectors' -import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' import { describe, expect, it } from 'vitest' +import { getVaultAPR, getVaultStaking } from './kongVaultSelectors' -const buildVault = (chainId: number): TKongVault => - ({ - chainId, - address: '0x0000000000000000000000000000000000000001', - name: 'Test Vault', - symbol: 'yvTEST', - apiVersion: '3.0.0', - decimals: 18, - asset: { - address: '0x0000000000000000000000000000000000000002', - name: 'USDC', - symbol: 'USDC', - decimals: 6 - }, - tvl: 1_000_000, - performance: { - oracle: { apr: 0.04, apy: 0.04 }, - estimated: { - apr: 0.2, - apy: 0.2, - type: 'estimated', - components: {} - }, - historical: { - net: 0.03, - weeklyNet: 0.03, - monthlyNet: 0.02, - inceptionNet: 0.01 - } - }, - fees: { - managementFee: 0.0025, - performanceFee: 0.1 - }, - 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 - } - }) as TKongVault +const LIST_REWARD = { + address: '0x3333333333333333333333333333333333333333', + name: 'List Reward', + symbol: 'LR', + decimals: 18, + price: 1, + isFinished: false, + finishedAt: 0, + apr: 0.5, + perWeek: 10 +} -const SNAPSHOT = { - performance: { - estimated: { - apr: 0.15, - apy: 0.15, - type: 'estimated', - components: {} - }, - oracle: { - apr: 0.07, - apy: 0.07 - }, - historical: { - net: 0.02, - weeklyNet: 0.02, - monthlyNet: 0.02, - inceptionNet: 0.02 - } - }, - apy: { - net: 0.02, - label: 'estimated', - grossApr: 0.02, - weeklyNet: 0.02, - monthlyNet: 0.02, - inceptionNet: 0.02, - pricePerShare: '1000000000000000000', - weeklyPricePerShare: '1000000000000000000', - monthlyPricePerShare: '1000000000000000000' - }, - fees: { - managementFee: 0.0025, - performanceFee: 0.1 - } -} as unknown as TKongVaultSnapshot +const SNAPSHOT_REWARD = { + address: '0x4444444444444444444444444444444444444444', + name: 'Snapshot Reward', + symbol: 'SR', + decimals: 6, + price: 2, + isFinished: true, + finishedAt: 123, + apr: 1.5, + perWeek: 20 +} -describe('getVaultAPR forward base selection', () => { - it('prefers oracle APY for Katana vaults', () => { - const apr = getVaultAPR(buildVault(747474), SNAPSHOT) - expect(apr.forwardAPR.netAPR).toBeCloseTo(0.07, 8) - }) +describe('getVaultStaking', () => { + it('preserves list staking source and rewards when snapshot metadata is missing', () => { + const vault = { + staking: { + address: '0x2222222222222222222222222222222222222222', + available: false, + source: 'yBOLD', + rewards: [LIST_REWARD] + } + } as any - it('keeps estimated APY precedence for non-Katana vaults', () => { - const apr = getVaultAPR(buildVault(1), SNAPSHOT) - expect(apr.forwardAPR.netAPR).toBeCloseTo(0.15, 8) + const staking = getVaultStaking(vault, { + staking: { + address: '0x2222222222222222222222222222222222222222', + available: true + } + } as any) + + expect(staking.source).toBe('yBOLD') + expect(staking.rewards ?? []).toHaveLength(1) + expect(staking.rewards?.[0].symbol).toBe('LR') }) -}) -describe('getVaultAPR Katana component fallbacks', () => { - it('falls back to list estimated components when snapshot components are missing', () => { - const vault = buildVault(747474) - if (vault.performance?.estimated) { - vault.performance.estimated.components = { - baseAPR: 0.11, - katanaBonusAPY: 0.06, - katanaAppRewardsAPR: 0.09, - steerPointsPerDollar: 0.18, - fixedRateKatanaRewards: 0.35 + it('prefers snapshot staking source and rewards when they are present', () => { + const vault = { + staking: { + address: '0x2222222222222222222222222222222222222222', + available: false, + source: 'legacy', + rewards: [LIST_REWARD] } - } + } as any - const snapshotWithoutComponents = { - ...SNAPSHOT, - performance: { - ...SNAPSHOT.performance, - estimated: { - apr: 0.15, - apy: 0.15, - type: 'estimated' - } + const staking = getVaultStaking(vault, { + staking: { + address: '0x2222222222222222222222222222222222222222', + available: true, + source: 'VeYFI', + rewards: [SNAPSHOT_REWARD] } - } as unknown as TKongVaultSnapshot + } as any) - const apr = getVaultAPR(vault, snapshotWithoutComponents) + expect(staking.source).toBe('VeYFI') + expect(staking.rewards ?? []).toHaveLength(1) + expect(staking.rewards?.[0].symbol).toBe('SR') + }) +}) + +describe('getVaultAPR', () => { + it('uses list pricePerShare when snapshot pricePerShare is missing', () => { + const apr = getVaultAPR({ + chainId: 1, + address: '0x1111111111111111111111111111111111111111', + name: 'Vault', + symbol: 'yvTEST', + decimals: 18, + asset: { + address: '0x2222222222222222222222222222222222222222', + name: 'USDC', + symbol: 'USDC', + decimals: 6 + }, + tvl: 1000, + performance: { + oracle: { apr: 0.02, apy: 0.02 }, + estimated: { apr: 0.02, apy: 0.02, type: 'oracle', components: {} }, + historical: { net: 0.01, weeklyNet: 0.01, monthlyNet: 0.01, 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: null, + pricePerShare: '1050000' + } as any) - expect(apr.forwardAPR.composite.baseAPR).toBeCloseTo(0.11, 8) - expect(apr.extra.katanaBonusAPY).toBeCloseTo(0.06, 8) - expect(apr.extra.katanaAppRewardsAPR).toBeCloseTo(0.09, 8) - expect(apr.extra.steerPointsPerDollar).toBeCloseTo(0.18, 8) - expect(apr.extra.fixedRateKatanaRewards).toBeCloseTo(0.35, 8) + expect(apr.pricePerShare.today).toBeCloseTo(1.05, 8) }) }) diff --git a/src/components/pages/vaults/domain/kongVaultSelectors.ts b/src/components/pages/vaults/domain/kongVaultSelectors.ts index d02e7274f..5308cccbc 100644 --- a/src/components/pages/vaults/domain/kongVaultSelectors.ts +++ b/src/components/pages/vaults/domain/kongVaultSelectors.ts @@ -1,6 +1,6 @@ import { normalizeVaultCategory } from '@pages/vaults/utils/normalizeVaultCategory' import { toAddress, toBigInt, toNormalizedBN } from '@shared/utils' -import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' +import type { TKongVaultListItem, TKongVaultListItemStakingReward } from '@shared/utils/schemas/kongVaultListSchema' import type { TKongVaultSnapshot, TKongVaultSnapshotComposition, @@ -306,7 +306,7 @@ export type TKongVaultStaking = { address: `0x${string}` available: boolean source: string - rewards: TKongVaultStakingReward[] + rewards: TKongVaultStakingReward[] | null } export type TKongVaultMigration = { @@ -332,7 +332,7 @@ export type TKongVaultStrategy = { name: string description: string netAPR: number | null - estimatedAPY?: number + estimatedAPY?: number | null status: 'active' | 'not_active' | 'unallocated' details?: { totalDebt: string @@ -644,7 +644,7 @@ export const getVaultAPR = (vault: TKongVaultInput, snapshot?: TKongVaultSnapsho inception: pickNumber(snapshot?.apy?.inceptionNet ?? null, historical?.inceptionNet) }, pricePerShare: { - today: normalizePricePerShare(snapshot?.apy?.pricePerShare, token.decimals), + today: normalizePricePerShare(snapshot?.apy?.pricePerShare ?? vault.pricePerShare, token.decimals), weekAgo: normalizePricePerShare(snapshot?.apy?.weeklyPricePerShare, token.decimals), monthAgo: normalizePricePerShare(snapshot?.apy?.monthlyPricePerShare, token.decimals) }, @@ -656,8 +656,8 @@ export const getVaultAPR = (vault: TKongVaultInput, snapshot?: TKongVaultSnapsho } } -const mapSnapshotStakingRewards = ( - rewards: TKongVaultSnapshotStakingReward[] | undefined +const mapStakingRewards = ( + rewards: TKongVaultSnapshotStakingReward[] | TKongVaultListItemStakingReward[] | undefined ): TKongVaultStakingReward[] => { if (!rewards || rewards.length === 0) { return [] @@ -686,8 +686,8 @@ export const getVaultStaking = (vault: TKongVaultInput, snapshot?: TKongVaultSna return { address: toAddress(snapshotStaking?.address ?? listStaking?.address ?? zeroAddress), available: Boolean(snapshotStaking?.available ?? listStaking?.available ?? false), - source: snapshotStaking?.source ?? '', - rewards: mapSnapshotStakingRewards(snapshotStaking?.rewards) + source: snapshotStaking?.source ?? listStaking?.source ?? '', + rewards: mapStakingRewards(snapshotStaking?.rewards ?? listStaking?.rewards) } } diff --git a/src/components/pages/vaults/domain/normalizeVault.test.ts b/src/components/pages/vaults/domain/normalizeVault.test.ts new file mode 100644 index 000000000..80f46f6ad --- /dev/null +++ b/src/components/pages/vaults/domain/normalizeVault.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { + getCanonicalHoldingsVaultAddress, + getHoldingsAliasVaultAddress, + YBOLD_STAKING_ADDRESS, + YBOLD_VAULT_ADDRESS +} from './normalizeVault' + +describe('holdings alias helpers', () => { + it('maps the yBOLD staking wrapper to the base vault', () => { + expect(getHoldingsAliasVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) + expect(getCanonicalHoldingsVaultAddress(YBOLD_STAKING_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) + }) + + it('keeps non-aliased vaults canonicalized to themselves', () => { + expect(getHoldingsAliasVaultAddress(YBOLD_VAULT_ADDRESS)).toBeUndefined() + expect(getCanonicalHoldingsVaultAddress(YBOLD_VAULT_ADDRESS)).toBe(YBOLD_VAULT_ADDRESS) + }) +}) diff --git a/src/components/pages/vaults/domain/normalizeVault.ts b/src/components/pages/vaults/domain/normalizeVault.ts index 0d06a36a8..6c13186ac 100644 --- a/src/components/pages/vaults/domain/normalizeVault.ts +++ b/src/components/pages/vaults/domain/normalizeVault.ts @@ -7,12 +7,18 @@ 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 = { + [toAddress(YBOLD_STAKING_ADDRESS)]: YBOLD_VAULT_ADDRESS +} + export function mergeYBoldVault(baseVault: TKongVaultListItem, stakedVault: TKongVaultListItem): TKongVaultListItem { return { ...baseVault, staking: { address: YBOLD_STAKING_ADDRESS, - available: true + available: true, + source: 'yBOLD', + rewards: stakedVault.staking?.rewards ?? baseVault.staking?.rewards ?? [] }, performance: { ...(baseVault.performance ?? {}), @@ -93,3 +99,15 @@ export function patchYBoldVaults(vaults: TDict): TDict { + it('matches the expected copy', () => { + expect(NON_YEARN_ERC4626_WARNING_MESSAGE).toBe( + 'This is a non-Yearn ERC-4626 Vault. Please be careful when interacting with it.' + ) + }) +}) + +describe('isNonYearnErc4626Vault', () => { + it('returns false for catalog Yearn vaults', () => { + expect( + isNonYearnErc4626Vault({ + vault: { + origin: 'yearn', + inclusion: { isYearn: true } + } as any + }) + ).toBe(false) + }) + + it('returns true when the list origin is missing', () => { + expect( + isNonYearnErc4626Vault({ + vault: { + origin: null, + inclusion: {} + } as any + }) + ).toBe(true) + }) + + it('returns true when the list origin is not yearn', () => { + expect( + isNonYearnErc4626Vault({ + vault: { + origin: 'partner', + inclusion: { isYearn: true } + } as any + }) + ).toBe(true) + }) + + it('returns true when inclusion explicitly marks the vault as non-Yearn', () => { + expect( + isNonYearnErc4626Vault({ + vault: { + origin: 'yearn', + inclusion: { isYearn: false } + } as any + }) + ).toBe(true) + }) + + it('returns true from snapshot metadata when list metadata is unavailable', () => { + expect( + isNonYearnErc4626Vault({ + snapshot: { + inclusion: { isYearn: false } + } as any + }) + ).toBe(true) + }) +}) diff --git a/src/components/pages/vaults/domain/vaultWarnings.ts b/src/components/pages/vaults/domain/vaultWarnings.ts new file mode 100644 index 000000000..690e2dba2 --- /dev/null +++ b/src/components/pages/vaults/domain/vaultWarnings.ts @@ -0,0 +1,23 @@ +import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' + +export const NON_YEARN_ERC4626_WARNING_MESSAGE = + 'This is a non-Yearn ERC-4626 Vault. Please be careful when interacting with it.' + +export function isNonYearnErc4626Vault({ + vault, + snapshot +}: { + vault?: TKongVault + snapshot?: TKongVaultSnapshot +}): boolean { + if (vault) { + return vault.origin !== 'yearn' || vault.inclusion?.isYearn === false + } + + if (snapshot?.inclusion?.isYearn === false) { + return true + } + + return false +} diff --git a/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts b/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts new file mode 100644 index 000000000..12e0933c9 --- /dev/null +++ b/src/components/pages/vaults/hooks/actions/stakingAdapter.test.ts @@ -0,0 +1,188 @@ +import { erc4626Abi } from '@shared/contracts/abi/4626.abi' +import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' +import { TOKENIZED_STRATEGY_ABI } from '@shared/contracts/abi/tokenizedStrategy.abi' +import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' +import { describe, expect, it } from 'vitest' +import { + getDirectStakeCall, + getDirectUnstakeCalls, + getStakePreviewCall, + getStakingWithdrawableAssets, + normalizeStakingSource +} from './stakingAdapter' + +describe('stakingAdapter', () => { + it('normalizes known and unknown staking sources', () => { + expect(normalizeStakingSource('VeYFI')).toBe('VeYFI') + expect(normalizeStakingSource('yBOLD')).toBe('yBOLD') + expect(normalizeStakingSource('Legacy')).toBe('default') + }) + + it('builds stake preview calls for source-specific ERC4626 staking', () => { + const amount = 42n + expect(getStakePreviewCall('VeYFI', amount)).toMatchObject({ + abi: VEYFI_GAUGE_ABI, + functionName: 'previewDeposit', + args: [amount] + }) + expect(getStakePreviewCall('yBOLD', amount)).toMatchObject({ + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'previewDeposit', + args: [amount] + }) + expect(getStakePreviewCall('Legacy', amount)).toBeUndefined() + }) + + it('builds direct stake calls per staking source', () => { + const amount = 100n + const account = '0x1111111111111111111111111111111111111111' + + expect(getDirectStakeCall({ stakingSource: 'VeYFI', amount, account })).toMatchObject({ + abi: VEYFI_GAUGE_ABI, + functionName: 'deposit', + args: [amount] + }) + + expect(getDirectStakeCall({ stakingSource: 'yBOLD', amount, account })).toMatchObject({ + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'deposit', + args: [amount, account] + }) + + expect(getDirectStakeCall({ stakingSource: 'Legacy', amount, account })).toMatchObject({ + abi: STAKING_REWARDS_ABI, + functionName: 'stake', + args: [amount] + }) + }) + + it('builds direct unstake calls with source-first + fallback behavior', () => { + const amount = 321n + const account = '0x1111111111111111111111111111111111111111' + + const yboldCalls = getDirectUnstakeCalls({ stakingSource: 'yBOLD', amount, account }) + expect(yboldCalls.primary).toMatchObject({ + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'withdraw', + args: [amount, account, account] + }) + expect(yboldCalls.fallback).toMatchObject({ + abi: STAKING_REWARDS_ABI, + functionName: 'withdraw', + args: [amount] + }) + + const defaultCalls = getDirectUnstakeCalls({ stakingSource: 'Legacy', amount, account }) + expect(defaultCalls.primary).toMatchObject({ + abi: STAKING_REWARDS_ABI, + functionName: 'withdraw', + args: [amount] + }) + expect(defaultCalls.fallback).toMatchObject({ + abi: erc4626Abi, + functionName: 'withdraw', + args: [amount, account, account] + }) + }) + + it('falls back to maxWithdraw when maxRedeem conversion is unavailable', async () => { + const account = '0x1111111111111111111111111111111111111111' + const stakingAddress = '0x2222222222222222222222222222222222222222' + const read = async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') throw new Error('missing') + if (functionName === 'maxWithdraw') return 123n + return 0n + } + + const result = await getStakingWithdrawableAssets({ + read, + stakingAddress, + account, + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + + expect(result).toBe(123n) + }) + + it('prefers maxRedeem + convertToAssets for ERC4626 wrappers', async () => { + const account = '0x1111111111111111111111111111111111111111' + const stakingAddress = '0x2222222222222222222222222222222222222222' + const read = async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') return 99n + if (functionName === 'convertToAssets') return 150n + if (functionName === 'maxWithdraw') return 123n + return 0n + } + + const result = await getStakingWithdrawableAssets({ + read, + stakingAddress, + account, + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + + expect(result).toBe(150n) + }) + + it('falls back to convertToAssets and then raw balance for withdrawable assets', async () => { + const account = '0x1111111111111111111111111111111111111111' + const stakingAddress = '0x2222222222222222222222222222222222222222' + + const convertRead = async ({ + functionName + }: { + functionName: string + address: `0x${string}` + abi: readonly unknown[] + args?: readonly unknown[] + }) => { + if (functionName === 'maxRedeem') { + throw new Error('missing') + } + if (functionName === 'maxWithdraw') { + throw new Error('missing') + } + if (functionName === 'convertToAssets') { + return 456n + } + return 0n + } + + const converted = await getStakingWithdrawableAssets({ + read: convertRead, + stakingAddress, + account, + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + expect(converted).toBe(456n) + + const failingRead = async () => { + throw new Error('missing') + } + const fallback = await getStakingWithdrawableAssets({ + read: failingRead, + stakingAddress, + account, + stakingSource: 'yBOLD', + stakingShareBalance: 99n + }) + expect(fallback).toBe(99n) + }) +}) diff --git a/src/components/pages/vaults/hooks/actions/stakingAdapter.ts b/src/components/pages/vaults/hooks/actions/stakingAdapter.ts new file mode 100644 index 000000000..e248c6f08 --- /dev/null +++ b/src/components/pages/vaults/hooks/actions/stakingAdapter.ts @@ -0,0 +1,349 @@ +import { erc4626Abi } from '@shared/contracts/abi/4626.abi' +import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' +import { TOKENIZED_STRATEGY_ABI } from '@shared/contracts/abi/tokenizedStrategy.abi' +import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' +import type { Address } from 'viem' + +export type StakingSourceKind = 'VeYFI' | 'yBOLD' | 'default' + +export type StakingCall = { + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] +} + +type DirectStakeCallInput = { + stakingSource?: string + amount: bigint + account?: Address +} + +type DirectUnstakeCallInput = { + stakingSource?: string + amount: bigint + account?: Address + redeemAll?: boolean + maxRedeemShares?: bigint +} + +type StakingWithdrawableAssetsInput = { + read: (request: { + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] + }) => Promise + stakingAddress: Address + account: Address + stakingSource?: string + stakingShareBalance: bigint +} + +export function normalizeStakingSource(stakingSource?: string): StakingSourceKind { + if (stakingSource === 'VeYFI') return 'VeYFI' + if (stakingSource === 'yBOLD') return 'yBOLD' + return 'default' +} + +export function getStakePreviewCall(stakingSource: string | undefined, amount: bigint): StakingCall | undefined { + const source = normalizeStakingSource(stakingSource) + if (source === 'VeYFI') { + return { + abi: VEYFI_GAUGE_ABI, + functionName: 'previewDeposit', + args: [amount] + } + } + if (source === 'yBOLD') { + return { + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'previewDeposit', + args: [amount] + } + } + return undefined +} + +export function getDirectStakeCall({ stakingSource, amount, account }: DirectStakeCallInput): StakingCall { + const source = normalizeStakingSource(stakingSource) + + if (source === 'VeYFI') { + return { + abi: VEYFI_GAUGE_ABI, + functionName: 'deposit', + args: [amount] + } + } + + if (source === 'yBOLD') { + return { + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'deposit', + args: account ? [amount, account] : undefined + } + } + + return { + abi: STAKING_REWARDS_ABI, + functionName: 'stake', + args: [amount] + } +} + +export function getDirectUnstakeCalls({ + stakingSource, + amount, + account, + redeemAll, + maxRedeemShares +}: DirectUnstakeCallInput): { + primary: StakingCall + fallback?: StakingCall +} { + const source = normalizeStakingSource(stakingSource) + + const erc4626Call: StakingCall | undefined = account + ? { + abi: erc4626Abi, + functionName: 'withdraw', + args: [amount, account, account] + } + : undefined + + const rewardsCall: StakingCall = { + abi: STAKING_REWARDS_ABI, + functionName: 'withdraw', + args: [amount] + } + + const shouldRedeemAll = !!account && !!redeemAll && (maxRedeemShares ?? 0n) > 0n + const redeemShares = maxRedeemShares ?? 0n + + if (source === 'VeYFI') { + return { + primary: shouldRedeemAll + ? { + abi: VEYFI_GAUGE_ABI, + functionName: 'redeem', + args: [redeemShares, account, account] + } + : account + ? { + abi: VEYFI_GAUGE_ABI, + functionName: 'withdraw', + args: [amount, account, account] + } + : rewardsCall, + fallback: rewardsCall + } + } + + if (source === 'yBOLD') { + return { + primary: shouldRedeemAll + ? { + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'redeem', + args: [redeemShares, account, account] + } + : account + ? { + abi: TOKENIZED_STRATEGY_ABI, + functionName: 'withdraw', + args: [amount, account, account] + } + : rewardsCall, + fallback: rewardsCall + } + } + + return { + primary: rewardsCall, + fallback: erc4626Call + } +} + +async function readBigInt({ + read, + address, + abi, + functionName, + args +}: { + read: (request: { + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] + }) => Promise + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] +}): Promise { + try { + const value = await read({ + address, + abi, + functionName, + args + }) + return BigInt(value as bigint) + } catch { + return undefined + } +} + +async function readMaxRedeemConvertedAssets({ + read, + address, + abi, + account +}: { + read: (request: { + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] + }) => Promise + address: Address + abi: readonly unknown[] + account: Address +}): Promise { + const maxRedeem = await readBigInt({ + read, + address, + abi, + functionName: 'maxRedeem', + args: [account] + }) + if (maxRedeem === undefined) { + return undefined + } + + return readBigInt({ + read, + address, + abi, + functionName: 'convertToAssets', + args: [maxRedeem] + }) +} + +export async function getStakingWithdrawableAssets({ + read, + stakingAddress, + account, + stakingSource, + stakingShareBalance +}: StakingWithdrawableAssetsInput): Promise { + const source = normalizeStakingSource(stakingSource) + + const mappedAbi = source === 'VeYFI' ? VEYFI_GAUGE_ABI : source === 'yBOLD' ? TOKENIZED_STRATEGY_ABI : undefined + + if (mappedAbi) { + const mappedRedeemableAssets = await readMaxRedeemConvertedAssets({ + read, + address: stakingAddress, + abi: mappedAbi, + account + }) + if (mappedRedeemableAssets !== undefined) { + return mappedRedeemableAssets + } + + const mappedMaxWithdraw = await readBigInt({ + read, + address: stakingAddress, + abi: mappedAbi, + functionName: 'maxWithdraw', + args: [account] + }) + if (mappedMaxWithdraw !== undefined) { + return mappedMaxWithdraw + } + + const mappedConvertedAssets = await readBigInt({ + read, + address: stakingAddress, + abi: mappedAbi, + functionName: 'convertToAssets', + args: [stakingShareBalance] + }) + if (mappedConvertedAssets !== undefined) { + return mappedConvertedAssets + } + } + + const genericRedeemableAssets = await readMaxRedeemConvertedAssets({ + read, + address: stakingAddress, + abi: erc4626Abi, + account + }) + if (genericRedeemableAssets !== undefined) { + return genericRedeemableAssets + } + + const genericMaxWithdraw = await readBigInt({ + read, + address: stakingAddress, + abi: erc4626Abi, + functionName: 'maxWithdraw', + args: [account] + }) + if (genericMaxWithdraw !== undefined) { + return genericMaxWithdraw + } + + const genericConvertedAssets = await readBigInt({ + read, + address: stakingAddress, + abi: erc4626Abi, + functionName: 'convertToAssets', + args: [stakingShareBalance] + }) + if (genericConvertedAssets !== undefined) { + return genericConvertedAssets + } + + return stakingShareBalance +} + +export async function getStakingRedeemableShares({ + read, + stakingAddress, + account, + stakingSource, + stakingShareBalance +}: StakingWithdrawableAssetsInput): Promise { + const source = normalizeStakingSource(stakingSource) + + const mappedAbi = source === 'VeYFI' ? VEYFI_GAUGE_ABI : source === 'yBOLD' ? TOKENIZED_STRATEGY_ABI : undefined + + if (mappedAbi) { + const mappedMaxRedeem = await readBigInt({ + read, + address: stakingAddress, + abi: mappedAbi, + functionName: 'maxRedeem', + args: [account] + }) + if (mappedMaxRedeem !== undefined) { + return mappedMaxRedeem + } + } + + const genericMaxRedeem = await readBigInt({ + read, + address: stakingAddress, + abi: erc4626Abi, + functionName: 'maxRedeem', + args: [account] + }) + if (genericMaxRedeem !== undefined) { + return genericMaxRedeem + } + + return stakingShareBalance +} diff --git a/src/components/pages/vaults/hooks/actions/useDirectStake.ts b/src/components/pages/vaults/hooks/actions/useDirectStake.ts index e2cd07ddd..3a5fc84ce 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectStake.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectStake.ts @@ -1,11 +1,9 @@ import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' -import { STAKING_REWARDS_ABI } from '@shared/contracts/abi/stakingRewards.abi' -import { TOKENIZED_STRATEGY_ABI } from '@shared/contracts/abi/tokenizedStrategy.abi' -import { VEYFI_GAUGE_ABI } from '@shared/contracts/abi/veYFIGauge.abi' import type { Address } from 'viem' import { erc20Abi } from 'viem' import { type UseSimulateContractReturnType, useReadContract, useSimulateContract } from 'wagmi' import { useTokenAllowance } from '../useTokenAllowance' +import { getDirectStakeCall, getStakePreviewCall, normalizeStakingSource } from './stakingAdapter' interface UseDirectStakeParams { stakingAddress?: Address @@ -28,43 +26,22 @@ export function useDirectStake(params: UseDirectStakeParams): UseWidgetDepositFl chainId: params.chainId }) - // Fetch expected stake amount based on staking type - // For VeYFI: use previewDeposit - const { data: veYFIExpectedAmount = 0n } = useReadContract({ - address: params.stakingAddress, - abi: VEYFI_GAUGE_ABI, - functionName: 'previewDeposit', - args: [params.amount], - chainId: params.chainId, - query: { - enabled: params.enabled && params.stakingSource === 'VeYFI' && params.amount > 0n && !!params.stakingAddress - } - }) + const stakingSource = normalizeStakingSource(params.stakingSource) + const previewCall = getStakePreviewCall(params.stakingSource, params.amount) - // For yBOLD: use previewDeposit - const { data: yBOLDExpectedAmount = 0n } = useReadContract({ + const { data: previewExpectedAmountData } = useReadContract({ address: params.stakingAddress, - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'previewDeposit', - args: [params.amount], + abi: (previewCall?.abi || []) as any, + functionName: (previewCall?.functionName || 'previewDeposit') as any, + args: previewCall?.args as any, chainId: params.chainId, query: { - enabled: params.enabled && params.stakingSource === 'yBOLD' && params.amount > 0n && !!params.stakingAddress + enabled: params.enabled && params.amount > 0n && !!params.stakingAddress && !!previewCall } }) - // Calculate expected stake amount based on staking source - const expectedOut = (() => { - switch (params.stakingSource) { - case 'VeYFI': - return veYFIExpectedAmount - case 'yBOLD': - return yBOLDExpectedAmount - default: - // 1:1 for default staking - return params.amount - } - })() + const previewExpectedAmount = (previewExpectedAmountData as bigint | undefined) ?? 0n + const expectedOut = stakingSource === 'default' ? params.amount : previewExpectedAmount const isValidInput = params.amount > 0n && !!params.stakingAddress const isAllowanceSufficient = allowance >= params.amount @@ -81,36 +58,17 @@ export function useDirectStake(params: UseDirectStakeParams): UseWidgetDepositFl query: { enabled: prepareApproveEnabled } }) - // Prepare stake transaction (varies by staking source) - mapped to prepareDeposit for unified interface - const { abi, functionName, args } = (() => { - switch (params.stakingSource) { - case 'VeYFI': - return { - abi: VEYFI_GAUGE_ABI, - functionName: 'deposit' as const, - args: [params.amount] as const - } - case 'yBOLD': - return { - abi: TOKENIZED_STRATEGY_ABI, - functionName: 'deposit' as const, - args: [params.amount, params.account] as const - } - default: - // Default staking (OP Boost, V3 Staking, Juiced) - return { - abi: STAKING_REWARDS_ABI, - functionName: 'stake' as const, - args: [params.amount] as const - } - } - })() + const stakeCall = getDirectStakeCall({ + stakingSource: params.stakingSource, + amount: params.amount, + account: params.account + }) const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ - abi, - functionName, + abi: stakeCall.abi as any, + functionName: stakeCall.functionName as any, address: params.stakingAddress, - args: args as [bigint, Address], + args: stakeCall.args as any, account: params.account, chainId: params.chainId, query: { enabled: prepareDepositEnabled } diff --git a/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts b/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts index d46cf6980..92196fadf 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectUnstake.ts @@ -1,14 +1,17 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' -import { gaugeV2Abi } from '@shared/contracts/abi/gaugeV2.abi' import type { Address } from 'viem' import { maxUint256 } from 'viem' import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' +import { getDirectUnstakeCalls } from './stakingAdapter' interface UseDirectUnstakeParams { stakingAddress?: Address amount: bigint // vault token amount to unstake + redeemAll?: boolean + maxRedeemShares?: bigint account?: Address chainId: number + stakingSource?: string enabled: boolean } @@ -16,17 +19,40 @@ export function useDirectUnstake(params: UseDirectUnstakeParams): UseWidgetWithd const isValidInput = params.amount > 0n && !!params.stakingAddress const prepareWithdrawEnabled = isValidInput && !!params.account && params.enabled - // Prepare unstake transaction using gauge withdraw function - // withdraw(amount, receiver, owner) - no approval needed when owner == msg.sender - const prepareWithdraw: UseSimulateContractReturnType = useSimulateContract({ - abi: gaugeV2Abi, - functionName: 'withdraw', + const unstakeCalls = getDirectUnstakeCalls({ + stakingSource: params.stakingSource, + amount: params.amount, + account: params.account, + redeemAll: params.redeemAll, + maxRedeemShares: params.maxRedeemShares + }) + + const preparePrimaryWithdraw: UseSimulateContractReturnType = useSimulateContract({ + abi: unstakeCalls.primary.abi as any, + functionName: unstakeCalls.primary.functionName as any, address: params.stakingAddress, - args: params.stakingAddress && params.account ? [params.amount, params.account, params.account] : undefined, + args: unstakeCalls.primary.args as any, chainId: params.chainId, + account: params.account, query: { enabled: prepareWithdrawEnabled } }) + const shouldTryFallback = + prepareWithdrawEnabled && !!unstakeCalls.fallback && !!params.stakingAddress && preparePrimaryWithdraw.isError + + const prepareFallbackWithdraw: UseSimulateContractReturnType = useSimulateContract({ + abi: (unstakeCalls.fallback?.abi || []) as any, + functionName: (unstakeCalls.fallback?.functionName || 'withdraw') as any, + address: params.stakingAddress, + args: unstakeCalls.fallback?.args as any, + chainId: params.chainId, + account: params.account, + query: { enabled: shouldTryFallback } + }) + + const prepareWithdraw: UseSimulateContractReturnType = + unstakeCalls.fallback && preparePrimaryWithdraw.isError ? prepareFallbackWithdraw : preparePrimaryWithdraw + return { actions: { prepareWithdraw diff --git a/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts b/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts index dae9e4bf3..60fdb26a0 100644 --- a/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts +++ b/src/components/pages/vaults/hooks/actions/useDirectWithdraw.ts @@ -2,25 +2,60 @@ import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' import { erc4626Abi } from '@shared/contracts/abi/4626.abi' import { vaultAbi } from '@shared/contracts/abi/vaultV2.abi' import { toAddress } from '@shared/utils' +import { useMemo } from 'react' import type { Address } from 'viem' import { maxUint256 } from 'viem' import { type UseSimulateContractReturnType, useSimulateContract } from 'wagmi' interface UseDirectWithdrawParams { vaultAddress: Address - assetAddress: Address amount: bigint // desired underlying asset amount maxShares?: bigint // full share balance for redeem-all + redeemSharesOverride?: bigint // exact vault shares to redeem in fallback unstake->withdraw flows redeemAll?: boolean pricePerShare: bigint // pre-fetched from component account?: Address chainId: number - decimals: number // asset decimals vaultDecimals: number // vault decimals enabled: boolean useErc4626: boolean } +function computeExpectedOut(params: { + amount: bigint + pricePerShare: bigint + redeemAll: boolean + shouldRedeemExactShares: boolean + redeemShares: bigint + vaultDecimals: number +}): bigint { + if (!params.redeemAll && !params.shouldRedeemExactShares) { + return params.amount + } + + if (params.pricePerShare === 0n) { + return 0n + } + + return (params.redeemShares * params.pricePerShare) / 10n ** BigInt(params.vaultDecimals) +} + +function areContractArgsEqual(actual?: readonly unknown[], expected?: readonly unknown[]): boolean { + if (!actual && !expected) return true + if (!actual || !expected || actual.length !== expected.length) return false + + return actual.every((value, index) => { + const nextValue = expected[index] + if (typeof value === 'bigint' || typeof nextValue === 'bigint') { + return value === nextValue + } + if (typeof value === 'string' && typeof nextValue === 'string') { + return value.toLowerCase() === nextValue.toLowerCase() + } + return value === nextValue + }) +} + export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWithdrawFlowReturn { // Calculate required vault shares from desired underlying amount // Formula: requiredShares = (desiredUnderlying * 10^vaultDecimals) / pricePerShare @@ -29,24 +64,33 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit ? (params.amount * 10n ** BigInt(params.vaultDecimals) + params.pricePerShare - 1n) / params.pricePerShare : 0n + const redeemSharesOverride = params.redeemSharesOverride ?? 0n + const shouldRedeemExactShares = redeemSharesOverride > 0n const redeemAll = !!params.redeemAll && (params.maxShares ?? 0n) > 0n - const redeemShares = redeemAll ? (params.maxShares ?? 0n) : 0n + const redeemShares = shouldRedeemExactShares ? redeemSharesOverride : redeemAll ? (params.maxShares ?? 0n) : 0n - const isValidInput = redeemAll ? redeemShares > 0n : params.amount > 0n && requiredShares > 0n + const isValidInput = + shouldRedeemExactShares || redeemAll ? redeemShares > 0n : params.amount > 0n && requiredShares > 0n const prepareWithdrawEnabled = isValidInput && !!params.account && params.enabled + const accountAddress = prepareWithdrawEnabled && params.account ? toAddress(params.account) : undefined + const erc4626FunctionName = redeemShares > 0n ? 'redeem' : 'withdraw' + const erc4626Args: readonly [bigint, Address, Address] | undefined = accountAddress + ? redeemShares > 0n + ? [redeemShares, accountAddress, accountAddress] + : [params.amount, accountAddress, accountAddress] + : undefined + const withdrawV2Args: readonly [bigint, Address] | undefined = accountAddress + ? [redeemShares > 0n ? redeemShares : requiredShares, accountAddress] + : undefined // Prepare withdraw transaction using ERC4626 withdraw function // withdraw(assets, receiver, owner) - no approval needed when owner == msg.sender const prepareWithdrawErc4626: UseSimulateContractReturnType = useSimulateContract({ abi: erc4626Abi, - functionName: redeemAll ? 'redeem' : 'withdraw', + functionName: erc4626FunctionName, address: params.vaultAddress, - args: params.account - ? redeemAll - ? [redeemShares, toAddress(params.account), toAddress(params.account)] - : [params.amount, toAddress(params.account), toAddress(params.account)] - : undefined, - account: params.account ? toAddress(params.account) : undefined, + args: erc4626Args, + account: accountAddress, chainId: params.chainId, query: { enabled: prepareWithdrawEnabled && params.useErc4626 } }) @@ -55,19 +99,54 @@ export function useDirectWithdraw(params: UseDirectWithdrawParams): UseWidgetWit abi: vaultAbi, functionName: 'withdraw', address: params.vaultAddress, - args: params.account ? [redeemAll ? redeemShares : requiredShares, toAddress(params.account)] : undefined, - account: params.account ? toAddress(params.account) : undefined, + args: withdrawV2Args, + account: accountAddress, chainId: params.chainId, query: { enabled: prepareWithdrawEnabled && !params.useErc4626 } }) - const prepareWithdraw = params.useErc4626 ? prepareWithdrawErc4626 : prepareWithdrawV2 + const prepareWithdraw = useMemo((): UseSimulateContractReturnType => { + const livePrepare = params.useErc4626 ? prepareWithdrawErc4626 : prepareWithdrawV2 + const expectedArgs = params.useErc4626 ? erc4626Args : withdrawV2Args + const expectedFunctionName = params.useErc4626 ? erc4626FunctionName : 'withdraw' + const request = livePrepare.data?.request as + | { + args?: readonly unknown[] + functionName?: string + } + | undefined + const hasCurrentRequest = + prepareWithdrawEnabled && + request?.functionName === expectedFunctionName && + areContractArgsEqual(request.args, expectedArgs) - const expectedOut = redeemAll - ? params.pricePerShare > 0n - ? (redeemShares * params.pricePerShare) / 10n ** BigInt(params.vaultDecimals) - : 0n - : params.amount + if (hasCurrentRequest) { + return livePrepare + } + + return { + ...livePrepare, + data: undefined, + isSuccess: false + } as UseSimulateContractReturnType + }, [ + prepareWithdrawEnabled, + params.useErc4626, + prepareWithdrawErc4626, + prepareWithdrawV2, + erc4626Args, + withdrawV2Args, + erc4626FunctionName + ]) + + const expectedOut = computeExpectedOut({ + amount: params.amount, + pricePerShare: params.pricePerShare, + redeemAll, + shouldRedeemExactShares, + redeemShares, + vaultDecimals: params.vaultDecimals + }) return { actions: { diff --git a/src/components/pages/vaults/hooks/actions/useEnsoDeposit.ts b/src/components/pages/vaults/hooks/actions/useEnsoDeposit.ts index 2afc98a0d..469f81cd9 100644 --- a/src/components/pages/vaults/hooks/actions/useEnsoDeposit.ts +++ b/src/components/pages/vaults/hooks/actions/useEnsoDeposit.ts @@ -67,7 +67,7 @@ export function useEnsoDeposit(params: UseEnsoDepositParams): UseWidgetDepositFl }, periphery: { prepareApproveEnabled: ensoFlow.periphery.prepareApproveEnabled, - prepareDepositEnabled: !!ensoFlow.periphery.route && params.amount > 0n, + prepareDepositEnabled: Boolean(canDeposit && !ensoFlow.periphery.isLoadingRoute), isAllowanceSufficient: isEnsoAllowanceSufficient, allowance: ensoFlow.periphery.allowance, expectedOut: ensoFlow.periphery.expectedOut.raw, diff --git a/src/components/pages/vaults/hooks/useEnsoOrder.ts b/src/components/pages/vaults/hooks/useEnsoOrder.ts index b189bc1be..949b2f730 100644 --- a/src/components/pages/vaults/hooks/useEnsoOrder.ts +++ b/src/components/pages/vaults/hooks/useEnsoOrder.ts @@ -78,10 +78,11 @@ export const useEnsoOrder = ({ const ensoTx = getEnsoTransaction() useEffect(() => { + if (isExecuting || waitingForTx) return setError(null) setTxHash(undefined) setWaitingForTx(false) - }, [ensoTx?.data, ensoTx?.to, ensoTx?.value]) + }, [ensoTx?.data, ensoTx?.to, ensoTx?.value, isExecuting, waitingForTx]) // Handle receipt useEffect(() => { diff --git a/src/components/pages/vaults/hooks/useSortVaults.ts b/src/components/pages/vaults/hooks/useSortVaults.ts index e32808a2c..0202b115c 100644 --- a/src/components/pages/vaults/hooks/useSortVaults.ts +++ b/src/components/pages/vaults/hooks/useSortVaults.ts @@ -1,11 +1,9 @@ import { - getVaultAddress, getVaultAPR, getVaultChainID, getVaultFeaturingScore, getVaultInfo, getVaultName, - getVaultStaking, getVaultToken, getVaultTVL, type TKongVaultInput, @@ -13,7 +11,7 @@ import { } from '@pages/vaults/domain/kongVaultSelectors' import { useWallet } from '@shared/contexts/useWallet' import type { TSortDirection } from '@shared/types' -import { isZeroAddress, normalizeApyDisplayValue, toAddress, toNormalizedBN } from '@shared/utils' +import { normalizeApyDisplayValue, toAddress, toNormalizedBN } from '@shared/utils' import { ETH_TOKEN_ADDRESS, WETH_TOKEN_ADDRESS, WFTM_TOKEN_ADDRESS } from '@shared/utils/constants' import { numberSort, stringSort } from '@shared/utils/helpers' import { calculateVaultEstimatedAPY } from '@shared/utils/vaultApy' @@ -36,8 +34,7 @@ export function useSortVaults { if (sortBy !== 'featuringScore' || sortDirection !== 'desc') { return false @@ -52,24 +49,37 @@ export function useSortVaults { + const sortedVaults = useMemo((): TVault[] => { if (sortDirection === '' || isFeaturingScoreSortedDesc) { return vaultList } const getDepositedValue = (vault: TKongVaultInput): number => { - const chainID = getVaultChainID(vault) - const address = getVaultAddress(vault) - const staking = getVaultStaking(vault) - - const vaultToken = getToken({ address, chainID }) - const vaultValue = vaultToken.value || 0 + return getVaultHoldingsUsd(vault) + } - const stakingValue = !isZeroAddress(toAddress(staking?.address)) - ? getToken({ address: staking.address, chainID }).value || 0 + const getAvailableValue = (vault: TKongVaultInput): number => { + const token = getVaultToken(vault) + const chainID = getVaultChainID(vault) + const baseBalance = Number(getBalance({ address: token.address, chainID }).normalized || 0) + const nativeBalance = [WETH_TOKEN_ADDRESS, WFTM_TOKEN_ADDRESS].includes(toAddress(token.address)) + ? Number(getBalance({ address: ETH_TOKEN_ADDRESS, chainID }).normalized || 0) : 0 + return baseBalance + nativeBalance + } + + const depositedValueByVault = new Map() + if (sortBy === 'deposited') { + vaultList.forEach((vault) => { + depositedValueByVault.set(vault, getDepositedValue(vault)) + }) + } - return vaultValue + stakingValue + const availableValueByVault = new Map() + if (sortBy === 'available') { + vaultList.forEach((vault) => { + availableValueByVault.set(vault, getAvailableValue(vault)) + }) } switch (sortBy) { @@ -91,7 +101,7 @@ export function useSortVaults { const aprA = getVaultAPR(a).netAPR || 0 const aprB = getVaultAPR(b).netAPR || 0 @@ -103,7 +113,6 @@ export function useSortVaults numberSort({ a: getVaultTVL(a).tvl, b: getVaultTVL(b).tvl, sortDirection }) @@ -125,31 +134,16 @@ export function useSortVaults numberSort({ - a: getDepositedValue(a), - b: getDepositedValue(b), + a: depositedValueByVault.get(a) || 0, + b: depositedValueByVault.get(b) || 0, sortDirection }) ) case 'available': return vaultList.toSorted((a, b): number => { - const tokenA = getVaultToken(a) - const tokenB = getVaultToken(b) - const chainA = getVaultChainID(a) - const chainB = getVaultChainID(b) - - const aBaseBalance = Number(getBalance({ address: tokenA.address, chainID: chainA })?.normalized || 0) - const bBaseBalance = Number(getBalance({ address: tokenB.address, chainID: chainB })?.normalized || 0) - const aEthBalance = [WETH_TOKEN_ADDRESS, WFTM_TOKEN_ADDRESS].includes(toAddress(tokenA.address)) - ? Number(getBalance({ address: ETH_TOKEN_ADDRESS, chainID: chainA })?.normalized || 0) - : 0 - const bEthBalance = [WETH_TOKEN_ADDRESS, WFTM_TOKEN_ADDRESS].includes(toAddress(tokenB.address)) - ? Number(getBalance({ address: ETH_TOKEN_ADDRESS, chainID: chainB })?.normalized || 0) - : 0 - const aBalance = aBaseBalance + aEthBalance - const bBalance = bBaseBalance + bEthBalance - - const direction = sortDirection === 'asc' ? 1 : -1 - return direction * (aBalance - bBalance) + const aValue = availableValueByVault.get(a) || 0 + const bValue = availableValueByVault.get(b) || 0 + return numberSort({ a: aValue, b: bValue, sortDirection }) }) case 'featuringScore': return vaultList.toSorted((a, b): number => @@ -167,7 +161,7 @@ export function useSortVaults { - const contract = getContract({ - address, - abi: erc20Abi, - client - }) + try { + const contract = getContract({ + address, + abi: erc20Abi, + client + }) + const [balanceResult, decimalsResult, symbolResult, nameResult] = await Promise.allSettled([ + account ? contract.read.balanceOf([account]) : Promise.resolve(0n), + contract.read.decimals(), + contract.read.symbol(), + contract.read.name() + ]) - const [decimals, symbol, name, balance] = await Promise.all([ - contract.read.decimals(), - contract.read.symbol(), - contract.read.name(), - account ? contract.read.balanceOf([account]) : Promise.resolve(0n) - ]) + const balance = balanceResult.status === 'fulfilled' ? balanceResult.value : 0n + const decimals = decimalsResult.status === 'fulfilled' ? Number(decimalsResult.value) : 18 + const symbol = symbolResult.status === 'fulfilled' ? String(symbolResult.value) : '???' + const name = nameResult.status === 'fulfilled' ? String(nameResult.value) : 'Unknown' - return { - address, - decimals: Number(decimals), - symbol: String(symbol), - name: String(name), - chainID: chainId, - balance: toNormalizedBN(balance, Number(decimals)) + return { + address, + decimals, + symbol, + name, + chainID: chainId, + balance: toNormalizedBN(balance, decimals) + } + } catch (error) { + console.error(`Failed to fetch token ${address}:`, error) + return { + address, + decimals: 18, + symbol: '???', + name: 'Unknown', + chainID: chainId, + balance: toNormalizedBN(0n, 18) + } } }) ) diff --git a/src/components/pages/vaults/hooks/useVaultUserData.ts b/src/components/pages/vaults/hooks/useVaultUserData.ts index ea75f7a1c..75d45d499 100644 --- a/src/components/pages/vaults/hooks/useVaultUserData.ts +++ b/src/components/pages/vaults/hooks/useVaultUserData.ts @@ -1,9 +1,11 @@ import { VAULT_V3_ABI } from '@shared/contracts/abi/vaultV3.abi' +import { toNormalizedBN } from '@shared/utils' import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo } from 'react' import { type Address, getContract } from 'viem' import { useConfig } from 'wagmi' -import { getClient } from 'wagmi/actions' +import { getClient, readContract } from 'wagmi/actions' +import { getStakingRedeemableShares, getStakingWithdrawableAssets } from './actions/stakingAdapter' import { type Token, useTokens } from './useTokens' export interface VaultUserData { @@ -19,6 +21,8 @@ export interface VaultUserData { availableToDeposit: bigint depositedShares: bigint depositedValue: bigint + stakingWithdrawableAssets: bigint + stakingRedeemableShares: bigint // State isLoading: boolean @@ -29,6 +33,7 @@ interface UseVaultUserDataParams { vaultAddress: Address assetAddress: Address stakingAddress?: Address + stakingSource?: string chainId: number account?: Address } @@ -37,6 +42,7 @@ export const useVaultUserData = ({ vaultAddress, assetAddress, stakingAddress, + stakingSource, chainId, account }: UseVaultUserDataParams): VaultUserData => { @@ -76,20 +82,105 @@ export const useVaultUserData = ({ refetchOnReconnect: false }) + // Derive tokens + const [assetToken, vaultToken, rawStakingToken] = tokens + + const stakingToken = useMemo(() => { + if (!rawStakingToken) { + return undefined + } + + const metadataMissing = rawStakingToken.symbol === '???' || rawStakingToken.name === 'Unknown' + if (!metadataMissing) { + return rawStakingToken + } + + const fallbackDecimals = vaultToken?.decimals ?? rawStakingToken.decimals ?? 18 + return { + ...rawStakingToken, + decimals: fallbackDecimals, + symbol: vaultToken?.symbol ?? rawStakingToken.symbol, + name: vaultToken?.name ?? rawStakingToken.name, + balance: toNormalizedBN(rawStakingToken.balance.raw, fallbackDecimals) + } + }, [rawStakingToken, vaultToken?.decimals, vaultToken?.symbol, vaultToken?.name]) + + const stakingShareBalance = stakingToken?.balance.raw ?? 0n + + const { + data: stakingCapacity, + isLoading: isLoadingStakingWithdrawableAssets, + refetch: refetchStakingWithdrawableAssets + } = useQuery({ + queryKey: [ + 'stakingWithdrawableAssets', + stakingAddress?.toLowerCase(), + account?.toLowerCase(), + chainId, + stakingSource || '', + stakingShareBalance.toString() + ], + queryFn: async () => { + if (!stakingAddress || !account) { + return { + withdrawableAssets: stakingShareBalance, + redeemableShares: stakingShareBalance + } + } + + const read = (request: { + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] + }) => + readContract(config, { + chainId, + address: request.address, + abi: request.abi as any, + functionName: request.functionName as any, + args: request.args as any + }) + + const [withdrawableAssets, redeemableShares] = await Promise.all([ + getStakingWithdrawableAssets({ + read, + stakingAddress, + account, + stakingSource, + stakingShareBalance + }), + getStakingRedeemableShares({ + read, + stakingAddress, + account, + stakingSource, + stakingShareBalance + }) + ]) + + return { withdrawableAssets, redeemableShares } + }, + enabled: !!stakingAddress && !!account && !!chainId, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false + }) + // Combined refetch const refetch = useCallback(() => { refetchTokens() refetchPPS() - }, [refetchTokens, refetchPPS]) + refetchStakingWithdrawableAssets() + }, [refetchTokens, refetchPPS, refetchStakingWithdrawableAssets]) - // Derive tokens - const [assetToken, vaultToken, stakingToken] = tokens + const effectiveStakingWithdrawableAssets = stakingCapacity?.withdrawableAssets ?? stakingShareBalance + const effectiveStakingRedeemableShares = stakingCapacity?.redeemableShares ?? stakingShareBalance const depositedShares = useMemo(() => { const vaultBalance = vaultToken?.balance.raw ?? 0n - const stakingBalance = stakingToken?.balance.raw ?? 0n - return vaultBalance + stakingBalance - }, [vaultToken, stakingToken]) + return vaultBalance + effectiveStakingWithdrawableAssets + }, [vaultToken, effectiveStakingWithdrawableAssets]) const depositedValue = useMemo(() => { if (!pricePerShare || depositedShares === 0n) return 0n @@ -105,7 +196,9 @@ export const useVaultUserData = ({ availableToDeposit: assetToken?.balance.raw ?? 0n, depositedShares, depositedValue, - isLoading: isLoadingTokens || isLoadingPPS, + stakingWithdrawableAssets: effectiveStakingWithdrawableAssets, + stakingRedeemableShares: effectiveStakingRedeemableShares, + isLoading: isLoadingTokens || isLoadingPPS || isLoadingStakingWithdrawableAssets, refetch } } diff --git a/src/components/pages/vaults/hooks/useVaultsPageModel.ts b/src/components/pages/vaults/hooks/useVaultsPageModel.ts index f0a9ab112..b2a0172cf 100644 --- a/src/components/pages/vaults/hooks/useVaultsPageModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsPageModel.ts @@ -63,10 +63,10 @@ import { } from 'react' import { useVaultsListModel } from './useVaultsListModel' import { useVaultsQueryState } from './useVaultsQueryState' +import { VAULTS_FILTERS_STORAGE_KEY } from './vaultsFiltersStorage' const DEFAULT_VAULT_TYPES = ['multi', 'single'] const DEFAULT_SORT_BY: TPossibleSortBy = 'tvl' -const VAULTS_FILTERS_STORAGE_KEY = 'yearn.fi/vaults-filters@1' type TVaultsPinnedSection = { key: string diff --git a/src/components/pages/vaults/hooks/vaultsFiltersStorage.ts b/src/components/pages/vaults/hooks/vaultsFiltersStorage.ts new file mode 100644 index 000000000..d149191ea --- /dev/null +++ b/src/components/pages/vaults/hooks/vaultsFiltersStorage.ts @@ -0,0 +1,15 @@ +import { useLocalStorage } from '@shared/hooks/useLocalStorage' + +export const VAULTS_FILTERS_STORAGE_KEY = 'yearn.fi/vaults-filters@1' + +type TVaultsPersistedFilters = { + showHiddenVaults?: boolean +} + +export function usePersistedShowHiddenVaults(): boolean { + const [snapshot] = useLocalStorage(VAULTS_FILTERS_STORAGE_KEY, { + showHiddenVaults: false + }) + + return Boolean(snapshot?.showHiddenVaults) +} diff --git a/src/components/pages/vaults/utils/holdingsValue.ts b/src/components/pages/vaults/utils/holdingsValue.ts new file mode 100644 index 000000000..84d11b013 --- /dev/null +++ b/src/components/pages/vaults/utils/holdingsValue.ts @@ -0,0 +1,53 @@ +import { + getVaultAddress, + getVaultAPR, + getVaultChainID, + getVaultDecimals, + getVaultStaking, + getVaultToken, + getVaultTVL, + type TKongVaultInput +} from '@pages/vaults/domain/kongVaultSelectors' +import type { TAddress } from '@shared/types/address' +import type { TNormalizedBN } from '@shared/types/mixed' +import { isZeroAddress, toNormalizedBN } from '@shared/utils' + +type TTokenAndChain = { address: TAddress; chainID: number } +type TBalanceGetter = (params: TTokenAndChain) => TNormalizedBN +type TPriceGetter = (params: TTokenAndChain) => { normalized: number } + +export function getVaultSharePriceUsd(vault: TKongVaultInput, getPrice: TPriceGetter): number { + const chainID = getVaultChainID(vault) + const vaultAddress = getVaultAddress(vault) + const directSharePrice = getPrice({ address: vaultAddress, chainID }).normalized + if (directSharePrice > 0) { + return directSharePrice + } + + const assetToken = getVaultToken(vault) + const assetPrice = getPrice({ address: assetToken.address, chainID }).normalized + const pricePerShare = getVaultAPR(vault).pricePerShare.today + if (assetPrice > 0 && pricePerShare > 0) { + return assetPrice * pricePerShare + } + + return getVaultTVL(vault).price +} + +export function getVaultHoldingsUsd( + vault: TKongVaultInput, + getBalance: TBalanceGetter, + getPrice: TPriceGetter +): number { + const chainID = getVaultChainID(vault) + const vaultDecimals = getVaultDecimals(vault) + const vaultAddress = getVaultAddress(vault) + const staking = getVaultStaking(vault) + + const vaultBalanceRaw = getBalance({ address: vaultAddress, chainID }).raw + const stakingBalanceRaw = !isZeroAddress(staking.address) ? getBalance({ address: staking.address, chainID }).raw : 0n + const totalShares = toNormalizedBN(vaultBalanceRaw + stakingBalanceRaw, vaultDecimals).normalized + + const sharePriceUsd = getVaultSharePriceUsd(vault, getPrice) + return totalShares * sharePriceUsd +} diff --git a/src/components/pages/vaults/utils/vaultTagCopy.ts b/src/components/pages/vaults/utils/vaultTagCopy.ts index 8532838e8..fa88b554d 100644 --- a/src/components/pages/vaults/utils/vaultTagCopy.ts +++ b/src/components/pages/vaults/utils/vaultTagCopy.ts @@ -27,6 +27,7 @@ const CHAIN_WEBSITES: Record = { export const RETIRED_TAG_DESCRIPTION = 'Deposits are disabled; withdrawals remain available.' export const MIGRATABLE_TAG_DESCRIPTION = 'A retired vault with a migration path available to a newer vault.' export const HIDDEN_TAG_DESCRIPTION = 'Hidden from the default list. Enable hidden vaults to view.' +export const NOT_YEARN_TAG_DESCRIPTION = 'This vault is not managed by Yearn. Review the issuer and risks carefully.' export function getChainDescription(chainId: number): string { return CHAIN_DESCRIPTIONS[chainId] || `${getNetwork(chainId).name} network.` diff --git a/src/components/shared/contexts/useWallet.tsx b/src/components/shared/contexts/useWallet.tsx index 97076e233..a246c15e5 100644 --- a/src/components/shared/contexts/useWallet.tsx +++ b/src/components/shared/contexts/useWallet.tsx @@ -1,10 +1,19 @@ -import { getVaultStaking, getVaultVersion, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { + getVaultAddress, + getVaultChainID, + getVaultStaking, + getVaultVersion, + type TKongVaultInput +} from '@pages/vaults/domain/kongVaultSelectors' +import { getCanonicalHoldingsVaultAddress } from '@pages/vaults/domain/normalizeVault' import { useDeepCompareMemo } from '@react-hookz/web' import type { ReactElement } from 'react' -import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react' +import { createContext, memo, useCallback, useContext, useMemo, useRef } from 'react' import type { TUseBalancesTokens } from '../hooks/useBalances.multichains' import { useBalancesCombined } from '../hooks/useBalancesCombined' import { useBalancesWithQuery } from '../hooks/useBalancesWithQuery' +import { useStakingAssetConversions } from '../hooks/useStakingAssetConversions' +import { getVaultHoldingsUsdValue } from '../hooks/useVaultFilterUtils' import type { TAddress, TChainTokens, TDict, TNDict, TNormalizedBN, TToken, TYChainTokens } from '../types' import { DEFAULT_ERC20, isZeroAddress, toAddress, zeroNormalizedBN } from '../utils' import { useWeb3 } from './useWeb3' @@ -18,6 +27,7 @@ type TTokenAndChain = { address: TAddress; chainID: number } type TWalletContext = { getToken: ({ address, chainID }: TTokenAndChain) => TToken getBalance: ({ address, chainID }: TTokenAndChain) => TNormalizedBN + getVaultHoldingsUsd: (vault: TKongVaultInput) => number balances: TChainTokens isLoading: boolean cumulatedValueInV2Vaults: number @@ -32,6 +42,7 @@ type TWalletContext = { const defaultProps = { getToken: (): TToken => DEFAULT_ERC20, getBalance: (): TNormalizedBN => zeroNormalizedBN, + getVaultHoldingsUsd: (): number => 0, balances: {}, isLoading: true, cumulatedValueInV2Vaults: 0, @@ -48,17 +59,16 @@ export const WalletContextApp = memo(function WalletContextApp(props: { children: ReactElement shouldWorkOnTestnet?: boolean }): ReactElement { - const { vaults, isLoadingVaultList } = useYearn() + const { vaults, allVaults, isLoadingVaultList, getPrice } = useYearn() const { address: userAddress } = useWeb3() const allTokens = useYearnTokens({ - vaults, + vaults: allVaults, + catalogVaults: vaults, isLoadingVaultList, isEnabled: Boolean(userAddress) }) - const { getToken: getTokenListToken } = useTokenList() - const useBalancesHook = USE_ENSO_BALANCES ? useBalancesCombined : useBalancesWithQuery const { data: tokensRaw, // Expected to be TDict @@ -75,12 +85,6 @@ export const WalletContextApp = memo(function WalletContextApp(props: { return _tokens as TYChainTokens }, [tokensRaw]) - useEffect(() => { - if (Object.keys(balances).length > 0) { - console.log({ balances, source: USE_ENSO_BALANCES ? 'enso' : 'multicall' }) - } - }, [balances]) - const onRefresh = useCallback( async (tokenToUpdate?: TUseBalancesTokens[]): Promise => { if (tokenToUpdate) { @@ -112,7 +116,7 @@ export const WalletContextApp = memo(function WalletContextApp(props: { // If balances is empty (during refetch), return cached token if available return tokenCache.current[cacheKey] || getTokenListToken({ address, chainID }) }, - [balances, userAddress] + [balances, userAddress, getTokenListToken] ) /************************************************************************** @@ -124,34 +128,57 @@ export const WalletContextApp = memo(function WalletContextApp(props: { [balances] ) + const stakingConvertedAssets = useStakingAssetConversions({ + allVaults, + getBalance, + userAddress + }) + + const getVaultHoldingsUsd = useCallback( + (vault: TKongVaultInput): number => + getVaultHoldingsUsdValue(vault, getToken, getBalance, getPrice, { + allVaults, + stakingConvertedAssets + }), + [allVaults, getBalance, getPrice, getToken, stakingConvertedAssets] + ) + const [cumulatedValueInV2Vaults, cumulatedValueInV3Vaults] = useMemo((): [number, number] => { // Build staking address → vault address lookup const stakingToVault = new Map() - for (const [vaultAddress, vault] of Object.entries(vaults)) { - const staking = getVaultStaking(vault as TKongVault) - if (staking?.address && !isZeroAddress(toAddress(staking.address))) { + for (const [vaultAddress, vault] of Object.entries(allVaults)) { + const staking = getVaultStaking(vault) + if (!isZeroAddress(toAddress(staking.address))) { stakingToVault.set(toAddress(staking.address), vaultAddress) } } let cumulatedValueInV2Vaults = 0 let cumulatedValueInV3Vaults = 0 + const countedVaults = new Set() for (const [_chainId, perChain] of Object.entries(balances)) { - for (const [tokenAddress, tokenData] of Object.entries(perChain)) { + for (const [tokenAddress] of Object.entries(perChain)) { const normalizedAddress = toAddress(tokenAddress) + const canonicalAddress = getCanonicalHoldingsVaultAddress(normalizedAddress) // Resolve vault details (direct vault or via staking lookup) - let vaultDetails = vaults?.[normalizedAddress] + let vaultDetails = allVaults?.[canonicalAddress] + if (!vaultDetails && stakingToVault.has(canonicalAddress)) { + vaultDetails = allVaults?.[stakingToVault.get(canonicalAddress)!] + } if (!vaultDetails && stakingToVault.has(normalizedAddress)) { - vaultDetails = vaults?.[stakingToVault.get(normalizedAddress)!] + vaultDetails = allVaults?.[stakingToVault.get(normalizedAddress)!] } if (!vaultDetails) continue + const vaultKey = `${getVaultChainID(vaultDetails)}/${toAddress(getVaultAddress(vaultDetails))}` + if (countedVaults.has(vaultKey)) continue + countedVaults.add(vaultKey) - const tokenValue = tokenData.value || 0 - const version = getVaultVersion(vaultDetails as TKongVault) - const isV3 = version.split('.')?.[0] === '3' || version.split('.')?.[0] === '~3' + const tokenValue = getVaultHoldingsUsd(vaultDetails) + const vaultVersion = getVaultVersion(vaultDetails) + const isV3 = vaultVersion.startsWith('3') || vaultVersion.startsWith('~3') if (isV3) { cumulatedValueInV3Vaults += tokenValue @@ -161,7 +188,7 @@ export const WalletContextApp = memo(function WalletContextApp(props: { } } return [cumulatedValueInV2Vaults, cumulatedValueInV3Vaults] - }, [balances, vaults]) + }, [allVaults, balances, getVaultHoldingsUsd]) /*************************************************************************** ** Setup and render the Context provider to use in the app. @@ -170,13 +197,23 @@ export const WalletContextApp = memo(function WalletContextApp(props: { (): TWalletContext => ({ getToken, getBalance, + getVaultHoldingsUsd, balances, isLoading: isLoading || false, onRefresh, cumulatedValueInV2Vaults, cumulatedValueInV3Vaults }), - [getToken, getBalance, balances, isLoading, onRefresh, cumulatedValueInV2Vaults, cumulatedValueInV3Vaults] + [ + getToken, + getBalance, + getVaultHoldingsUsd, + balances, + isLoading, + onRefresh, + cumulatedValueInV2Vaults, + cumulatedValueInV3Vaults + ] ) return {props.children} diff --git a/src/components/shared/contexts/useYearn.helper.tsx b/src/components/shared/contexts/useYearn.helper.tsx index 2d6396bf0..112307112 100644 --- a/src/components/shared/contexts/useYearn.helper.tsx +++ b/src/components/shared/contexts/useYearn.helper.tsx @@ -8,22 +8,52 @@ import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { getHoldingsAliasVaultAddress } from '@pages/vaults/domain/normalizeVault' import { useDeepCompareMemo } from '@react-hookz/web' import { useTokenList } from '@shared/contexts/WithTokenList' import type { TUseBalancesTokens } from '@shared/hooks/useBalances.multichains' import { useChainID } from '@shared/hooks/useChainID' import type { TDict } from '@shared/types' -import { toAddress } from '@shared/utils' +import { isZeroAddress, toAddress } from '@shared/utils' import { ETH_TOKEN_ADDRESS } from '@shared/utils/constants' import { getNetwork } from '@shared/utils/wagmi' import { useMemo } from 'react' +function mergeTokenMetadata(existing: TUseBalancesTokens, incoming: TUseBalancesTokens): TUseBalancesTokens { + return { + address: existing.address || incoming.address, + chainID: existing.chainID || incoming.chainID, + decimals: existing.decimals || incoming.decimals, + name: existing.name || incoming.name, + symbol: existing.symbol || incoming.symbol, + for: existing.for || incoming.for, + isVaultToken: Boolean(existing.isVaultToken || incoming.isVaultToken) || undefined, + isStakingToken: Boolean(existing.isStakingToken || incoming.isStakingToken) || undefined, + isCatalogVault: + existing.isCatalogVault === false || incoming.isCatalogVault === false + ? false + : (existing.isCatalogVault ?? incoming.isCatalogVault), + isStakingOnlyPair: Boolean(existing.isStakingOnlyPair || incoming.isStakingOnlyPair) || undefined, + isVaultBackedStaking: Boolean(existing.isVaultBackedStaking || incoming.isVaultBackedStaking) || undefined, + holdingsAliasVaultAddress: existing.holdingsAliasVaultAddress || incoming.holdingsAliasVaultAddress, + pairedVaultAddress: existing.pairedVaultAddress || incoming.pairedVaultAddress, + pairedStakingAddress: existing.pairedStakingAddress || incoming.pairedStakingAddress + } +} + +function upsertToken(tokens: TDict, key: string, incoming: TUseBalancesTokens): void { + const existing = tokens[key] + tokens[key] = existing ? mergeTokenMetadata(existing, incoming) : incoming +} + export function useYearnTokens({ vaults, + catalogVaults, isLoadingVaultList, isEnabled = true }: { vaults: TDict + catalogVaults?: TDict isLoadingVaultList: boolean isEnabled?: boolean }): TUseBalancesTokens[] { @@ -89,84 +119,93 @@ export function useYearnTokens({ tokens[key] = token } + const tokenListAddressSet = new Set( + availableTokenListTokens.map((token) => `${token.chainID}/${toAddress(token.address)}`) + ) + + const vaultAddressKeys = new Set( + allVaults.map((vault) => `${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}`) + ) + const catalogVaultKeys = new Set( + Object.values(catalogVaults ?? {}).map( + (vault) => `${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}` + ) + ) + allVaults.forEach((vault?: TKongVault): void => { if (!vault) { return } const chainID = getVaultChainID(vault) - const address = getVaultAddress(vault) + const address = toAddress(getVaultAddress(vault)) const name = getVaultName(vault) const symbol = getVaultSymbol(vault) const decimals = getVaultDecimals(vault) const token = getVaultToken(vault) const staking = getVaultStaking(vault) + const vaultKey = `${chainID}/${address}` + const holdingsAliasVaultAddress = getHoldingsAliasVaultAddress(address) + const stakingAddress = !isZeroAddress(toAddress(staking.address)) ? toAddress(staking.address) : undefined + const hasStaking = Boolean(stakingAddress) + const isVaultBackedStaking = hasStaking ? vaultAddressKeys.has(`${chainID}/${stakingAddress}`) : false + const isStakingOnlyPair = hasStaking && !isVaultBackedStaking + + upsertToken(tokens, vaultKey, { + address, + chainID, + symbol, + decimals, + name, + for: 'vault-share', + isVaultToken: true, + isCatalogVault: catalogVaultKeys.has(vaultKey), + isStakingOnlyPair: hasStaking ? isStakingOnlyPair : undefined, + isVaultBackedStaking: hasStaking ? isVaultBackedStaking : undefined, + holdingsAliasVaultAddress, + pairedStakingAddress: stakingAddress + }) - if (!tokens[`${chainID}/${toAddress(address)}`]) { - tokens[`${chainID}/${toAddress(address)}`] = { - address, - chainID, - symbol, - decimals, - name - } - } else { - const existingToken = tokens[`${chainID}/${toAddress(address)}`] - - if (existingToken) { - if (!existingToken?.name && name) { - tokens[`${chainID}/${toAddress(address)}`].name = name - } - if (!existingToken?.symbol && symbol) { - tokens[`${chainID}/${toAddress(address)}`].symbol = symbol - } - if (!existingToken?.decimals && decimals) { - tokens[`${chainID}/${toAddress(address)}`].decimals = decimals - } - } - } - - if (token.address && !availableTokenListTokens.some((item) => item.address === token.address)) { - tokens[`${chainID}/${toAddress(token.address)}`] = { - address: token.address, - chainID, - decimals: token.decimals + if (token.address) { + const vaultAssetTokenKey = `${chainID}/${toAddress(token.address)}` + if (!tokenListAddressSet.has(vaultAssetTokenKey)) { + upsertToken(tokens, vaultAssetTokenKey, { + address: token.address, + chainID, + decimals: token.decimals + }) } } - if (staking.available && !tokens[`${chainID}/${toAddress(staking.address)}`]) { - tokens[`${chainID}/${toAddress(staking.address)}`] = { - address: toAddress(staking.address), + if (stakingAddress) { + const stakingKey = `${chainID}/${stakingAddress}` + upsertToken(tokens, stakingKey, { + address: stakingAddress, chainID, symbol, decimals, - name - } - } else { - const existingToken = tokens[`${chainID}/${toAddress(staking.address)}`] - if (existingToken) { - if (!existingToken?.name && name) { - tokens[`${chainID}/${toAddress(staking.address)}`].name = name - } - if (!existingToken?.symbol && symbol) { - tokens[`${chainID}/${toAddress(staking.address)}`].symbol = symbol - } - if (!existingToken?.decimals && decimals) { - tokens[`${chainID}/${toAddress(staking.address)}`].decimals = decimals - } - } + name, + for: 'vault-staking', + isStakingToken: true, + isCatalogVault: catalogVaultKeys.has(stakingKey), + isStakingOnlyPair, + isVaultBackedStaking, + holdingsAliasVaultAddress: getHoldingsAliasVaultAddress(stakingAddress), + pairedVaultAddress: address + }) } }) return tokens - }, [isEnabled, isLoadingVaultList, allVaults, availableTokenListTokens]) + }, [isEnabled, isLoadingVaultList, allVaults, availableTokenListTokens, catalogVaults]) const allTokens = useDeepCompareMemo((): TUseBalancesTokens[] => { if (!isEnabled || isLoadingVaultList) { return [] } const fromAvailableTokens = Object.values(availableTokens) - return [...fromAvailableTokens, ...availableTokenListTokens] + const tokens = [...fromAvailableTokens, ...availableTokenListTokens] + return tokens }, [isEnabled, isLoadingVaultList, availableTokens, availableTokenListTokens]) function cloneForForknet(tokens: TUseBalancesTokens[]): TUseBalancesTokens[] { diff --git a/src/components/shared/contexts/useYearn.tsx b/src/components/shared/contexts/useYearn.tsx index 12f829fe6..b95489b1c 100755 --- a/src/components/shared/contexts/useYearn.tsx +++ b/src/components/shared/contexts/useYearn.tsx @@ -22,6 +22,7 @@ export type TYearnContext = { earned?: TYDaemonEarned prices?: TYDaemonPricesChain vaults: TDict + allVaults: TDict isLoadingVaultList: boolean zapSlippage: number maxLoss: bigint @@ -47,6 +48,7 @@ const YearnContext = createContext({ }, prices: {}, vaults: {}, + allVaults: {}, isLoadingVaultList: false, maxLoss: DEFAULT_MAX_LOSS, zapSlippage: 0.1, @@ -102,7 +104,7 @@ export const YearnContextApp = memo(function YearnContextApp({ children }: { chi const prices = useFetchYearnPrices() //RG this endpoint returns empty objects for retired and migrations - const { vaults, isLoading, refetch } = useFetchYearnVaults(undefined, { + const { vaults, allVaults, isLoading, refetch } = useFetchYearnVaults(undefined, { enabled: isVaultListEnabled }) @@ -127,6 +129,7 @@ export const YearnContextApp = memo(function YearnContextApp({ children }: { chi setZapProvider, setIsAutoStakingEnabled, vaults, + allVaults, isLoadingVaultList: isLoading, mutateVaultList: refetch, enableVaultListFetch, diff --git a/src/components/shared/hooks/balanceDiscoveryFallback.ts b/src/components/shared/hooks/balanceDiscoveryFallback.ts new file mode 100644 index 000000000..f7bbf96f7 --- /dev/null +++ b/src/components/shared/hooks/balanceDiscoveryFallback.ts @@ -0,0 +1,9 @@ +import type { TUseBalancesTokens } from './useBalances.multichains' + +export function shouldUseDiscoveryFallbackToken(params: { + token: TUseBalancesTokens + hasPositiveBalanceCache: boolean +}): boolean { + const { token, hasPositiveBalanceCache } = params + return Boolean(token.isStakingToken || token.isCatalogVault === false || hasPositiveBalanceCache) +} diff --git a/src/components/shared/hooks/useBalances.multichains.ts b/src/components/shared/hooks/useBalances.multichains.ts index 46264a195..0a273ac2e 100644 --- a/src/components/shared/hooks/useBalances.multichains.ts +++ b/src/components/shared/hooks/useBalances.multichains.ts @@ -22,7 +22,19 @@ export type TUseBalancesTokens = { decimals?: number name?: string symbol?: string + // Legacy token hint kept for backward compatibility. for?: string + isVaultToken?: boolean + isStakingToken?: boolean + isCatalogVault?: boolean + // A staking-only pair is a classic rewards contract token (not itself a vault token). + // These pairs should be fully sourced from multicall for deterministic accounting. + isStakingOnlyPair?: boolean + // A vault-backed staking pair means the staking token is also a vault token (e.g. yBOLD style). + isVaultBackedStaking?: boolean + holdingsAliasVaultAddress?: TAddress + pairedVaultAddress?: TAddress + pairedStakingAddress?: TAddress } export type TUseBalancesReq = { key?: string | number @@ -50,6 +62,19 @@ export type TUseBalancesRes = { type TUpdates = TDict const TOKEN_UPDATE: TUpdates = {} +export function hasPositiveCachedBalance(chainID: number, address: TAddress, ownerAddress?: TAddress): boolean { + if (!ownerAddress || isZeroAddress(ownerAddress)) { + return false + } + + const tokenUpdateInfo = TOKEN_UPDATE[`${chainID}/${toAddress(address)}`] + if (!tokenUpdateInfo) { + return false + } + + return toAddress(tokenUpdateInfo.owner) === toAddress(ownerAddress) && tokenUpdateInfo.balance.raw > 0n +} + export async function performCall( chainID: number, chunckCalls: MulticallParameters['contracts'], diff --git a/src/components/shared/hooks/useBalancesCombined.test.ts b/src/components/shared/hooks/useBalancesCombined.test.ts new file mode 100644 index 000000000..3d0390a2c --- /dev/null +++ b/src/components/shared/hooks/useBalancesCombined.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { shouldUseDiscoveryFallbackToken } from './balanceDiscoveryFallback' + +describe('shouldUseDiscoveryFallbackToken', () => { + it('uses discovery fallback for staking tokens', () => { + expect( + shouldUseDiscoveryFallbackToken({ + token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isStakingToken: true }, + hasPositiveBalanceCache: false + }) + ).toBe(true) + }) + + it('uses discovery fallback for non-catalog vault shares', () => { + expect( + shouldUseDiscoveryFallbackToken({ + token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: false }, + hasPositiveBalanceCache: false + }) + ).toBe(true) + }) + + it('uses discovery fallback for previously positive cached balances', () => { + expect( + shouldUseDiscoveryFallbackToken({ + token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: true }, + hasPositiveBalanceCache: true + }) + ).toBe(true) + }) + + it('skips discovery fallback for ordinary omitted catalog vault shares', () => { + expect( + shouldUseDiscoveryFallbackToken({ + token: { address: '0x1111111111111111111111111111111111111111', chainID: 1, isCatalogVault: true }, + hasPositiveBalanceCache: false + }) + ).toBe(false) + }) +}) diff --git a/src/components/shared/hooks/useBalancesCombined.ts b/src/components/shared/hooks/useBalancesCombined.ts index da847b862..59a2bb7be 100644 --- a/src/components/shared/hooks/useBalancesCombined.ts +++ b/src/components/shared/hooks/useBalancesCombined.ts @@ -4,11 +4,31 @@ import { useWeb3 } from '../contexts/useWeb3' import type { TChainTokens, TDict, TNDict, TToken } from '../types/mixed' import { toAddress } from '../utils/tools.address' import { isZeroAddress } from '../utils/tools.is' -import type { TChainStatus, TUseBalancesReq, TUseBalancesRes, TUseBalancesTokens } from './useBalances.multichains' +import { shouldUseDiscoveryFallbackToken } from './balanceDiscoveryFallback' +import { + hasPositiveCachedBalance, + type TChainStatus, + type TUseBalancesReq, + type TUseBalancesRes, + type TUseBalancesTokens +} from './useBalances.multichains' import { fetchTokenBalances, useBalancesQueries } from './useBalancesQueries' import { balanceQueryKeys } from './useBalancesQuery' +import { partitionTokensByBalanceSource } from './useBalancesRouting' import { ENSO_UNSUPPORTED_NETWORKS, useEnsoBalances } from './useEnsoBalances' +function mergeChainStatusMaps(...maps: TNDict[]): TNDict { + const merged: TNDict = {} + + maps.forEach((map) => { + Object.entries(map).forEach(([chainId, value]) => { + merged[Number(chainId)] = Boolean(merged[Number(chainId)] || value) + }) + }) + + return merged +} + /******************************************************************************* ** Combined balance hook that uses Enso API for supported chains ** and falls back to multicall (RPC) for unsupported chains like Fantom @@ -26,19 +46,8 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { const tokens = useMemo(() => (userAddress ? props?.tokens || [] : []), [props?.tokens, userAddress]) // Split tokens into Enso-supported and multicall-required groups - const { ensoTokens, multicallTokens } = useMemo(() => { - const enso: TUseBalancesTokens[] = [] - const multicall: TUseBalancesTokens[] = [] - - for (const token of tokens) { - if (ENSO_UNSUPPORTED_NETWORKS.includes(token.chainID)) { - multicall.push(token) - } else { - enso.push(token) - } - } - - return { ensoTokens: enso, multicallTokens: multicall } + const { ensoTokens, multicallTokens: requiredMulticallTokens } = useMemo(() => { + return partitionTokensByBalanceSource(tokens, ENSO_UNSUPPORTED_NETWORKS) }, [tokens]) // Fetch from Enso for supported chains @@ -56,25 +65,64 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { enabled: ensoTokens.length > 0 }) - // Fetch from multicall for unsupported chains (e.g., Fantom) + const discoveryFallbackTokens = useMemo((): TUseBalancesTokens[] => { + if (ensoTokens.length === 0) { + return [] + } + if (!userAddress) { + return [] + } + if (!ensoError && (!ensoSuccess || !ensoBalances)) { + return [] + } + + return ensoTokens.filter((token) => { + const tokenAddress = toAddress(token.address) + const hasEnsoBalance = Boolean(ensoBalances?.[token.chainID]?.[tokenAddress]) + if (hasEnsoBalance) { + return false + } + + return shouldUseDiscoveryFallbackToken({ + token, + hasPositiveBalanceCache: hasPositiveCachedBalance(token.chainID, tokenAddress, userAddress) + }) + }) + }, [ensoBalances, ensoError, ensoSuccess, ensoTokens, userAddress]) + + const { + data: requiredMulticallBalances, + isLoading: requiredMulticallLoading, + isError: requiredMulticallError, + isSuccess: requiredMulticallSuccess, + error: requiredMulticallErrorObj, + refetch: requiredMulticallRefetch, + chainLoadingStatus: requiredMulticallChainLoading, + chainSuccessStatus: requiredMulticallChainSuccess, + chainErrorStatus: requiredMulticallChainError + } = useBalancesQueries(userAddress, requiredMulticallTokens, { + enabled: requiredMulticallTokens.length > 0 + }) + const { - data: multicallBalances, - isLoading: multicallLoading, - isError: multicallError, - isSuccess: multicallSuccess, - error: multicallErrorObj, - refetch: multicallRefetch, - chainLoadingStatus: multicallChainLoading, - chainSuccessStatus: multicallChainSuccess, - chainErrorStatus: multicallChainError - } = useBalancesQueries(userAddress, multicallTokens, { - enabled: multicallTokens.length > 0 + data: discoveryFallbackBalances, + isLoading: discoveryFallbackLoading, + isError: discoveryFallbackError, + isSuccess: discoveryFallbackSuccess, + error: discoveryFallbackErrorObj, + refetch: discoveryFallbackRefetch, + chainLoadingStatus: discoveryFallbackChainLoading, + chainSuccessStatus: discoveryFallbackChainSuccess, + chainErrorStatus: discoveryFallbackChainError + } = useBalancesQueries(userAddress, discoveryFallbackTokens, { + enabled: discoveryFallbackTokens.length > 0 }) // Merge balances from both sources const balances = useMemo(() => { const hasEnsoData = ensoTokens.length > 0 - const hasMulticallData = multicallTokens.length > 0 + const hasRequiredMulticallData = requiredMulticallTokens.length > 0 + const hasDiscoveryFallbackData = discoveryFallbackTokens.length > 0 const result: TChainTokens = {} @@ -95,9 +143,24 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { } } - // Process multicall tokens (unsupported chains) - if (hasMulticallData && multicallBalances) { - for (const token of multicallTokens) { + if (hasRequiredMulticallData && requiredMulticallBalances) { + for (const token of requiredMulticallTokens) { + const chainId = token.chainID + const tokenAddress = toAddress(token.address) + + if (!result[chainId]) { + result[chainId] = {} + } + + const multicallToken = requiredMulticallBalances[chainId]?.[tokenAddress] + if (multicallToken) { + result[chainId][tokenAddress] = multicallToken + } + } + } + + if (hasDiscoveryFallbackData && discoveryFallbackBalances) { + for (const token of discoveryFallbackTokens) { const chainId = token.chainID const tokenAddress = toAddress(token.address) @@ -105,7 +168,7 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { result[chainId] = {} } - const multicallToken = multicallBalances[chainId]?.[tokenAddress] + const multicallToken = discoveryFallbackBalances[chainId]?.[tokenAddress] if (multicallToken) { result[chainId][tokenAddress] = multicallToken } @@ -113,54 +176,96 @@ export function useBalancesCombined(props?: TUseBalancesReq): TUseBalancesRes { } return result - }, [ensoBalances, multicallBalances, ensoTokens, multicallTokens]) + }, [ + discoveryFallbackBalances, + discoveryFallbackTokens, + ensoBalances, + ensoTokens, + requiredMulticallBalances, + requiredMulticallTokens + ]) // Combine loading/error/success states const isLoading = useMemo(() => { const ensoRelevant = ensoTokens.length > 0 - const multicallRelevant = multicallTokens.length > 0 - return (ensoRelevant && ensoLoading) || (multicallRelevant && multicallLoading) - }, [ensoTokens.length, multicallTokens.length, ensoLoading, multicallLoading]) + const requiredMulticallRelevant = requiredMulticallTokens.length > 0 + const discoveryRelevant = discoveryFallbackTokens.length > 0 + return ( + (ensoRelevant && ensoLoading) || + (requiredMulticallRelevant && requiredMulticallLoading) || + (discoveryRelevant && discoveryFallbackLoading) + ) + }, [ + discoveryFallbackLoading, + discoveryFallbackTokens.length, + ensoLoading, + ensoTokens.length, + requiredMulticallLoading, + requiredMulticallTokens.length + ]) const isError = useMemo(() => { const ensoRelevant = ensoTokens.length > 0 - const multicallRelevant = multicallTokens.length > 0 - // Only error if both sources that are relevant have errors - const ensoFailed = ensoRelevant && ensoError - const multicallFailed = multicallRelevant && multicallError - // If both are relevant, both must fail. If only one is relevant, that one must fail. - if (ensoRelevant && multicallRelevant) return ensoFailed && multicallFailed - return ensoFailed || multicallFailed - }, [ensoTokens.length, multicallTokens.length, ensoError, multicallError]) + const requiredMulticallRelevant = requiredMulticallTokens.length > 0 + const discoveryRelevant = discoveryFallbackTokens.length > 0 + return ( + (ensoRelevant && ensoError) || + (requiredMulticallRelevant && requiredMulticallError) || + (discoveryRelevant && discoveryFallbackError) + ) + }, [ + discoveryFallbackError, + discoveryFallbackTokens.length, + ensoError, + ensoTokens.length, + requiredMulticallError, + requiredMulticallTokens.length + ]) const isSuccess = useMemo(() => { const ensoRelevant = ensoTokens.length > 0 - const multicallRelevant = multicallTokens.length > 0 - // Success if at least one relevant source succeeds + const requiredMulticallRelevant = requiredMulticallTokens.length > 0 + const discoveryRelevant = discoveryFallbackTokens.length > 0 const ensoOk = !ensoRelevant || ensoSuccess - const multicallOk = !multicallRelevant || multicallSuccess - return ensoOk && multicallOk - }, [ensoTokens.length, multicallTokens.length, ensoSuccess, multicallSuccess]) - - const error = ensoErrorObj || multicallErrorObj || null + const requiredMulticallOk = !requiredMulticallRelevant || requiredMulticallSuccess + const discoveryOk = !discoveryRelevant || discoveryFallbackSuccess + return ensoOk && requiredMulticallOk && discoveryOk + }, [ + discoveryFallbackSuccess, + discoveryFallbackTokens.length, + ensoSuccess, + ensoTokens.length, + requiredMulticallSuccess, + requiredMulticallTokens.length + ]) + + const error = discoveryFallbackErrorObj || requiredMulticallErrorObj || ensoErrorObj || null // Merge chain status maps const chainLoadingStatus = useMemo((): TNDict => { - return { ...ensoChainLoading, ...multicallChainLoading } - }, [ensoChainLoading, multicallChainLoading]) + return mergeChainStatusMaps(ensoChainLoading, requiredMulticallChainLoading, discoveryFallbackChainLoading) + }, [discoveryFallbackChainLoading, ensoChainLoading, requiredMulticallChainLoading]) const chainSuccessStatus = useMemo((): TNDict => { - return { ...ensoChainSuccess, ...multicallChainSuccess } - }, [ensoChainSuccess, multicallChainSuccess]) + return mergeChainStatusMaps(ensoChainSuccess, requiredMulticallChainSuccess, discoveryFallbackChainSuccess) + }, [discoveryFallbackChainSuccess, ensoChainSuccess, requiredMulticallChainSuccess]) const chainErrorStatus = useMemo((): TNDict => { - return { ...ensoChainError, ...multicallChainError } - }, [ensoChainError, multicallChainError]) + return mergeChainStatusMaps(ensoChainError, requiredMulticallChainError, discoveryFallbackChainError) + }, [discoveryFallbackChainError, ensoChainError, requiredMulticallChainError]) const refetch = useCallback(() => { if (ensoTokens.length > 0) ensoRefetch() - if (multicallTokens.length > 0) multicallRefetch() - }, [ensoTokens.length, multicallTokens.length, ensoRefetch, multicallRefetch]) + if (requiredMulticallTokens.length > 0) requiredMulticallRefetch() + if (discoveryFallbackTokens.length > 0) discoveryFallbackRefetch() + }, [ + discoveryFallbackRefetch, + discoveryFallbackTokens.length, + ensoRefetch, + ensoTokens.length, + requiredMulticallRefetch, + requiredMulticallTokens.length + ]) const onUpdate = useCallback( async (shouldForceFetch?: boolean): Promise => { diff --git a/src/components/shared/hooks/useBalancesRouting.test.ts b/src/components/shared/hooks/useBalancesRouting.test.ts new file mode 100644 index 000000000..606529886 --- /dev/null +++ b/src/components/shared/hooks/useBalancesRouting.test.ts @@ -0,0 +1,127 @@ +import { getAddress } from 'viem' +import { describe, expect, it } from 'vitest' +import type { TUseBalancesTokens } from './useBalances.multichains' +import { partitionTokensByBalanceSource } from './useBalancesRouting' + +const VAULT_A = '0x1111111111111111111111111111111111111111' +const STAKING_A = '0x2222222222222222222222222222222222222222' +const VAULT_B = '0x3333333333333333333333333333333333333333' +const STAKING_B = '0x4444444444444444444444444444444444444444' +const VAULT_C = '0x5555555555555555555555555555555555555555' + +function tokenKey(token: TUseBalancesTokens): string { + return `${token.chainID}:${getAddress(token.address)}` +} + +describe('partitionTokensByBalanceSource', () => { + it('routes staking-only pair tokens to multicall', () => { + const tokens: TUseBalancesTokens[] = [ + { + address: VAULT_A, + chainID: 1, + for: 'vault', + isVaultToken: true, + isStakingOnlyPair: true, + pairedStakingAddress: STAKING_A + }, + { + address: STAKING_A, + chainID: 1, + for: 'staking', + isStakingToken: true, + isStakingOnlyPair: true, + pairedVaultAddress: VAULT_A + } + ] + + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) + expect(ensoTokens).toHaveLength(0) + expect(multicallTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_A)}`, `1:${getAddress(STAKING_A)}`]) + }) + + it('routes vault-backed staking tokens to enso on supported chains', () => { + const tokens: TUseBalancesTokens[] = [ + { + address: VAULT_B, + chainID: 1, + for: 'vault', + isVaultToken: true, + isVaultBackedStaking: true, + pairedStakingAddress: STAKING_B + }, + { + address: STAKING_B, + chainID: 1, + for: 'staking', + isVaultToken: true, + isStakingToken: true, + isVaultBackedStaking: true, + pairedVaultAddress: VAULT_B + } + ] + + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) + expect(multicallTokens).toHaveLength(0) + expect(ensoTokens.map(tokenKey)).toEqual([`1:${getAddress(VAULT_B)}`, `1:${getAddress(STAKING_B)}`]) + }) + + it('routes unsupported chains to multicall even for vault-backed staking', () => { + const tokens: TUseBalancesTokens[] = [ + { + address: STAKING_B, + chainID: 250, + for: 'staking', + isStakingToken: true, + isVaultBackedStaking: true + } + ] + + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, [250]) + expect(ensoTokens).toHaveLength(0) + expect(multicallTokens.map(tokenKey)).toEqual([`250:${getAddress(STAKING_B)}`]) + }) + + it('dedupes duplicate entries and never routes same token to both sources', () => { + const tokens: TUseBalancesTokens[] = [ + { address: VAULT_A, chainID: 1, for: 'vault' }, + { address: VAULT_A, chainID: 1 }, + { address: VAULT_C, chainID: 1, for: 'vault', isStakingOnlyPair: true, pairedStakingAddress: STAKING_A }, + { address: STAKING_A, chainID: 1, for: 'staking' }, + { address: STAKING_A, chainID: 1, isStakingToken: true, isStakingOnlyPair: true, pairedVaultAddress: VAULT_C } + ] + + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) + const ensoKeys = new Set(ensoTokens.map(tokenKey)) + const multicallKeys = new Set(multicallTokens.map(tokenKey)) + const overlap = [...ensoKeys].filter((key) => multicallKeys.has(key)) + + expect(overlap).toHaveLength(0) + expect([...ensoKeys]).toEqual([`1:${getAddress(VAULT_A)}`]) + expect([...multicallKeys]).toEqual([`1:${getAddress(VAULT_C)}`, `1:${getAddress(STAKING_A)}`]) + }) + + it('preserves discovery metadata when duplicate token entries are merged', () => { + const aliasVault = getAddress(VAULT_B) + const tokens: TUseBalancesTokens[] = [ + { + address: VAULT_A, + chainID: 1, + for: 'vault', + isCatalogVault: true + }, + { + address: VAULT_A, + chainID: 1, + isCatalogVault: false, + holdingsAliasVaultAddress: aliasVault + } + ] + + const { ensoTokens, multicallTokens } = partitionTokensByBalanceSource(tokens, []) + + expect(multicallTokens).toHaveLength(0) + expect(ensoTokens).toHaveLength(1) + expect(ensoTokens[0].isCatalogVault).toBe(false) + expect(ensoTokens[0].holdingsAliasVaultAddress).toBe(aliasVault) + }) +}) diff --git a/src/components/shared/hooks/useBalancesRouting.ts b/src/components/shared/hooks/useBalancesRouting.ts new file mode 100644 index 000000000..99382b49b --- /dev/null +++ b/src/components/shared/hooks/useBalancesRouting.ts @@ -0,0 +1,94 @@ +import { getAddress } from 'viem' +import type { TUseBalancesTokens } from './useBalances.multichains' + +type TBalanceSourcePartition = { + ensoTokens: TUseBalancesTokens[] + multicallTokens: TUseBalancesTokens[] +} + +function normalizeBalanceToken(token: TUseBalancesTokens): TUseBalancesTokens { + return { + ...token, + address: getAddress(token.address), + isVaultToken: Boolean(token.isVaultToken || token.for === 'vault'), + isStakingToken: Boolean(token.isStakingToken || token.for === 'staking') + } +} + +function mergeBalanceTokenMetadata(current: TUseBalancesTokens, next: TUseBalancesTokens): TUseBalancesTokens { + return { + address: current.address, + chainID: current.chainID, + decimals: current.decimals || next.decimals, + name: current.name || next.name, + symbol: current.symbol || next.symbol, + for: current.for || next.for, + isVaultToken: Boolean(current.isVaultToken || next.isVaultToken || current.for === 'vault' || next.for === 'vault'), + isStakingToken: Boolean( + current.isStakingToken || next.isStakingToken || current.for === 'staking' || next.for === 'staking' + ), + isCatalogVault: + current.isCatalogVault === false || next.isCatalogVault === false + ? false + : (current.isCatalogVault ?? next.isCatalogVault), + isStakingOnlyPair: Boolean(current.isStakingOnlyPair || next.isStakingOnlyPair), + isVaultBackedStaking: Boolean(current.isVaultBackedStaking || next.isVaultBackedStaking), + holdingsAliasVaultAddress: current.holdingsAliasVaultAddress || next.holdingsAliasVaultAddress, + pairedVaultAddress: current.pairedVaultAddress || next.pairedVaultAddress, + pairedStakingAddress: current.pairedStakingAddress || next.pairedStakingAddress + } +} + +function dedupeBalanceTokens(tokens: TUseBalancesTokens[]): TUseBalancesTokens[] { + const deduped = new Map() + + for (const rawToken of tokens) { + const token = normalizeBalanceToken(rawToken) + const key = `${token.chainID}:${token.address}` + const existing = deduped.get(key) + + if (!existing) { + deduped.set(key, token) + continue + } + + deduped.set(key, mergeBalanceTokenMetadata(existing, token)) + } + + return [...deduped.values()] +} + +function shouldUseMulticall(token: TUseBalancesTokens, unsupportedChains: Set): boolean { + if (unsupportedChains.has(token.chainID)) { + return true + } + + if (token.isStakingOnlyPair) { + return true + } + + if (token.isStakingToken && !token.isVaultBackedStaking) { + return true + } + + return false +} + +export function partitionTokensByBalanceSource( + tokens: TUseBalancesTokens[], + unsupportedNetworkIds: number[] +): TBalanceSourcePartition { + const unsupportedChains = new Set(unsupportedNetworkIds) + const ensoTokens: TUseBalancesTokens[] = [] + const multicallTokens: TUseBalancesTokens[] = [] + + for (const token of dedupeBalanceTokens(tokens)) { + if (shouldUseMulticall(token, unsupportedChains)) { + multicallTokens.push(token) + } else { + ensoTokens.push(token) + } + } + + return { ensoTokens, multicallTokens } +} diff --git a/src/components/shared/hooks/useEnsoBalances.ts b/src/components/shared/hooks/useEnsoBalances.ts index 73684d66f..aaa0e7693 100644 --- a/src/components/shared/hooks/useEnsoBalances.ts +++ b/src/components/shared/hooks/useEnsoBalances.ts @@ -39,7 +39,14 @@ async function fetchEnsoBalances(address: TAddress): Promise): 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 +} + +describe('isCatalogYearnVault', () => { + it('keeps yearn vaults in the public catalog by default', () => { + expect(isCatalogYearnVault(makeVault({ origin: 'yearn' }))).toBe(true) + }) + + it('excludes explicitly non-yearn catalog entries', () => { + expect(isCatalogYearnVault(makeVault({ origin: 'partner', inclusion: { isYearn: true } as never }))).toBe(false) + }) + + it('excludes yearn vaults that Kong marks as not included', () => { + expect(isCatalogYearnVault(makeVault({ origin: 'yearn', inclusion: { isYearn: false } as never }))).toBe(false) + }) +}) diff --git a/src/components/shared/hooks/useFetchYearnVaults.ts b/src/components/shared/hooks/useFetchYearnVaults.ts index 5c6d273bd..27feaaaab 100644 --- a/src/components/shared/hooks/useFetchYearnVaults.ts +++ b/src/components/shared/hooks/useFetchYearnVaults.ts @@ -11,14 +11,17 @@ import { useQueryClient } from '@tanstack/react-query' import { useEffect, useMemo } from 'react' const DEFAULT_CHAIN_IDS = [1, 10, 137, 146, 250, 8453, 42161, 747474] +const VAULT_LIST_ENDPOINT = `${KONG_REST_BASE}/list/vaults` -const VAULT_LIST_ENDPOINT = `${KONG_REST_BASE}/list/vaults?origin=yearn` +export const isCatalogYearnVault = (item: TKongVaultListItem): boolean => + item.origin === 'yearn' && item.inclusion?.isYearn !== false function useFetchYearnVaults( chainIDs?: number[] | undefined, options?: { enabled?: boolean } ): { vaults: TDict + allVaults: TDict isLoading: boolean refetch: () => Promise> } { @@ -37,27 +40,51 @@ function useFetchYearnVaults( } }) - const vaultsObject = useDeepCompareMemo((): TDict => { + const filteredByChain = useDeepCompareMemo((): TKongVaultListItem[] => { if (!kongVaultList) { - return {} + return [] } const chainIdSet = new Set(resolvedChainIds) - return kongVaultList - .filter((item) => item.inclusion?.isYearn !== false) - .filter((item) => chainIdSet.has(item.chainId)) - .reduce((acc: TDict, item): TDict => { - acc[toAddress(item.address)] = item - return acc - }, {}) + return kongVaultList.filter((item) => chainIdSet.has(item.chainId)) }, [kongVaultList, resolvedChainIds]) - const patchedVaultsObject = useDeepCompareMemo((): TDict => { - return patchYBoldVaults(vaultsObject) - }, [vaultsObject]) + const allVaultsObject = useDeepCompareMemo((): TDict => { + if (!filteredByChain.length) { + return {} + } + + return filteredByChain.reduce((acc: TDict, item): TDict => { + acc[toAddress(item.address)] = item + return acc + }, {}) + }, [filteredByChain]) + + const catalogVaultsObject = useDeepCompareMemo((): TDict => { + if (!filteredByChain.length) { + return {} + } + + return filteredByChain.reduce((acc: TDict, item): TDict => { + if (!isCatalogYearnVault(item)) { + return acc + } + acc[toAddress(item.address)] = item + return acc + }, {}) + }, [filteredByChain]) + + const patchedAllVaultsObject = useDeepCompareMemo((): TDict => { + return patchYBoldVaults(allVaultsObject) + }, [allVaultsObject]) + + const patchedCatalogVaultsObject = useDeepCompareMemo((): TDict => { + return patchYBoldVaults(catalogVaultsObject) + }, [catalogVaultsObject]) return { - vaults: patchedVaultsObject, + vaults: patchedCatalogVaultsObject, + allVaults: patchedAllVaultsObject, isLoading, refetch: refetch as unknown as () => Promise> } diff --git a/src/components/shared/hooks/useStakingAssetConversions.ts b/src/components/shared/hooks/useStakingAssetConversions.ts new file mode 100644 index 000000000..bd673fb8d --- /dev/null +++ b/src/components/shared/hooks/useStakingAssetConversions.ts @@ -0,0 +1,130 @@ +import { getVaultChainID, getVaultStaking, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { getStakingWithdrawableAssets } from '@pages/vaults/hooks/actions/stakingAdapter' +import { useDeepCompareMemo } from '@react-hookz/web' +import type { TAddress, TDict, TNormalizedBN } from '@shared/types' +import { isZeroAddress, toAddress } from '@shared/utils' +import { useQueries } from '@tanstack/react-query' +import { useMemo } from 'react' +import type { Address } from 'viem' +import { useConfig } from 'wagmi' +import { readContract } from 'wagmi/actions' + +type TTokenAndChain = { address: TAddress; chainID: number } +type TBalanceGetter = (params: TTokenAndChain) => TNormalizedBN + +type TStakingPosition = { + key: string + chainID: number + stakingAddress: Address + stakingSource: string + stakingShareBalance: bigint +} + +export function useStakingAssetConversions({ + allVaults, + getBalance, + userAddress +}: { + allVaults: TDict + getBalance: TBalanceGetter + userAddress?: Address +}): Record { + const config = useConfig() + + const stakingPositions = useDeepCompareMemo((): TStakingPosition[] => { + if (!userAddress || isZeroAddress(userAddress)) { + return [] + } + + const positions = new Map() + + Object.values(allVaults).forEach((vault) => { + const chainID = getVaultChainID(vault) + const staking = getVaultStaking(vault) + if (isZeroAddress(staking.address)) { + return + } + + const stakingAddress = toAddress(staking.address) + const stakingShareBalance = getBalance({ address: stakingAddress, chainID }).raw + if (stakingShareBalance <= 0n) { + return + } + + const key = `${chainID}/${stakingAddress}` + if (positions.has(key)) { + return + } + + positions.set(key, { + key, + chainID, + stakingAddress, + stakingSource: staking.source ?? '', + stakingShareBalance + }) + }) + + return [...positions.values()] + }, [allVaults, getBalance, userAddress]) + + const queries = useQueries({ + queries: stakingPositions.map((position) => ({ + queryKey: [ + 'walletStakingConvertedAssets', + userAddress?.toLowerCase(), + position.chainID, + position.stakingAddress.toLowerCase(), + position.stakingSource, + position.stakingShareBalance.toString() + ], + queryFn: async () => { + if (!userAddress) { + return undefined + } + + const read = (request: { + address: Address + abi: readonly unknown[] + functionName: string + args?: readonly unknown[] + }) => + readContract(config, { + chainId: position.chainID, + address: request.address, + abi: request.abi as any, + functionName: request.functionName as any, + args: request.args as any + }) + + return getStakingWithdrawableAssets({ + read, + stakingAddress: position.stakingAddress, + account: userAddress, + stakingSource: position.stakingSource, + stakingShareBalance: position.stakingShareBalance + }) + }, + enabled: Boolean(userAddress && position.stakingShareBalance > 0n), + staleTime: 60_000, + gcTime: 5 * 60_000, + retry: 1, + refetchOnWindowFocus: false + })) + }) + + return useMemo(() => { + const conversions: Record = {} + + queries.forEach((query, index) => { + const position = stakingPositions[index] + if (!position || query.data === undefined) { + return + } + + conversions[position.key] = query.data + }) + + return conversions + }, [queries, stakingPositions]) +} diff --git a/src/components/shared/hooks/useV2VaultFilter.ts b/src/components/shared/hooks/useV2VaultFilter.ts index ca8046a02..60b4dc495 100644 --- a/src/components/shared/hooks/useV2VaultFilter.ts +++ b/src/components/shared/hooks/useV2VaultFilter.ts @@ -1,7 +1,58 @@ -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' -import type { TVaultAggressiveness } from '@pages/vaults/utils/vaultListFacets' -import { type TVaultFilterResult, useVaultFilter } from './useVaultFilter' -import type { TVaultFlags } from './useVaultFilterUtils' +import { useAppSettings } from '@pages/vaults/contexts/useAppSettings' +import { + getVaultAddress, + getVaultChainID, + getVaultInfo, + getVaultMigration, + getVaultName, + getVaultStaking, + getVaultSymbol, + getVaultToken, + getVaultTVL, + type TKongVault +} from '@pages/vaults/domain/kongVaultSelectors' +import { getHoldingsAliasVaultAddress } from '@pages/vaults/domain/normalizeVault' +import { DEFAULT_MIN_TVL } from '@pages/vaults/utils/constants' +import { + deriveAssetCategory, + deriveListKind, + deriveV3Aggressiveness, + expandUnderlyingAssetSelection, + isAllocatorVaultOverride, + normalizeUnderlyingAssetSymbol, + type TVaultAggressiveness +} from '@pages/vaults/utils/vaultListFacets' +import { useDeepCompareMemo } from '@react-hookz/web' +import { useWallet } from '@shared/contexts/useWallet' +import { useYearn } from '@shared/contexts/useYearn' +import { isZeroAddress } from '@shared/utils' +import { useMemo } from 'react' +import { + createCheckHasAvailableBalance, + createCheckHasHoldings, + getVaultKey, + isV3Vault, + type TVaultFlags +} from './useVaultFilterUtils' + +type TVaultIndexEntry = { + key: string + vault: TKongVault + searchableText: string + kind: ReturnType + category: string + aggressiveness: TVaultAggressiveness | null + isHidden: boolean + isActive: boolean + isMigratable: boolean + isRetired: boolean + isBypassedHolding: boolean +} + +type TVaultWalletFlags = { + hasHoldings: boolean + hasAvailableBalance: boolean +} type TOptimizedV2VaultFilterResult = { filteredVaults: TKongVault[] @@ -13,18 +64,6 @@ type TOptimizedV2VaultFilterResult = { isLoading: boolean } -function toV2Result(result: TVaultFilterResult): TOptimizedV2VaultFilterResult { - return { - filteredVaults: result.filteredVaults, - holdingsVaults: result.holdingsVaults, - availableVaults: result.availableVaults, - vaultFlags: result.vaultFlags, - availableUnderlyingAssets: result.availableUnderlyingAssets, - underlyingAssetVaults: result.underlyingAssetVaults, - isLoading: result.isLoading - } -} - export function useV2VaultFilter( types: string[] | null, chains: number[] | null, @@ -36,18 +75,298 @@ export function useV2VaultFilter( showHiddenVaults?: boolean, enabled?: boolean ): TOptimizedV2VaultFilterResult { - const result = useVaultFilter({ - version: 'v2', + const { vaults, allVaults, getPrice, isLoadingVaultList } = useYearn() + const { getBalance } = useWallet() + const { shouldHideDust } = useAppSettings() + const isEnabled = enabled ?? true + const searchValue = search ?? '' + const minTvlValue = Number.isFinite(minTvl) ? Math.max(0, minTvl || 0) : DEFAULT_MIN_TVL + const isSearchEnabled = isEnabled && searchValue !== '' + const searchRegex = useMemo(() => { + if (!isSearchEnabled) { + return null + } + try { + const escapedSearch = searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(escapedSearch, 'i') + } catch { + return null + } + }, [isSearchEnabled, searchValue]) + const lowercaseSearch = useMemo( + () => (isSearchEnabled ? searchValue.toLowerCase() : ''), + [isSearchEnabled, searchValue] + ) + const normalizedUnderlyingAssets = useMemo(() => { + if (!underlyingAssets || underlyingAssets.length === 0) { + return new Set() + } + const normalized = underlyingAssets.map((asset) => normalizeUnderlyingAssetSymbol(asset)).filter(Boolean) + return new Set(normalized) + }, [underlyingAssets]) + const expandedUnderlyingAssets = useMemo( + () => expandUnderlyingAssetSelection(normalizedUnderlyingAssets), + [normalizedUnderlyingAssets] + ) + + const checkHasHoldings = useMemo( + () => createCheckHasHoldings(getBalance, getPrice, shouldHideDust), + [getBalance, getPrice, shouldHideDust] + ) + + const checkHasAvailableBalance = useMemo(() => createCheckHasAvailableBalance(getBalance), [getBalance]) + const checkHasRawHoldings = useMemo( + () => + (vault: TKongVault): boolean => { + const chainID = getVaultChainID(vault) + const vaultBalance = getBalance({ + address: getVaultAddress(vault), + chainID + }) + if (vaultBalance.raw > 0n) { + return true + } + + const staking = getVaultStaking(vault) + if (isZeroAddress(staking.address)) { + return false + } + + const stakingBalance = getBalance({ + address: staking.address, + chainID + }) + return stakingBalance.raw > 0n + }, + [getBalance] + ) + + const vaultIndex = useDeepCompareMemo(() => { + if (!isEnabled) { + return new Map() + } + const vaultMap = new Map() + + const shouldIncludeVault = (vault: TKongVault): boolean => + !isAllocatorVaultOverride(vault) && !isV3Vault(vault, false) + + const upsertVault = ( + vault: TKongVault, + updates: Partial> + ): void => { + const key = getVaultKey(vault) + const existing = vaultMap.get(key) + if (existing) { + vaultMap.set(key, { ...existing, ...updates }) + return + } + + const token = getVaultToken(vault) + const kind = deriveListKind(vault) + vaultMap.set(key, { + key, + vault, + searchableText: + `${getVaultName(vault)} ${getVaultSymbol(vault)} ${token.name} ${token.symbol} ${getVaultAddress(vault)} ${token.address}`.toLowerCase(), + kind, + category: deriveAssetCategory(vault), + aggressiveness: deriveV3Aggressiveness(vault), + isHidden: Boolean(getVaultInfo(vault)?.isHidden), + isActive: Boolean(updates.isActive), + isMigratable: Boolean(updates.isMigratable), + isRetired: Boolean(updates.isRetired), + isBypassedHolding: Boolean(updates.isBypassedHolding) + }) + } + + Object.values(vaults).forEach((vault) => { + if (getHoldingsAliasVaultAddress(getVaultAddress(vault))) { + return + } + if (!shouldIncludeVault(vault)) { + return + } + const isRetired = Boolean(getVaultInfo(vault)?.isRetired) + upsertVault(vault, { + isActive: !isRetired, + isRetired, + isMigratable: Boolean(getVaultMigration(vault)?.available) + }) + }) + + Object.values(allVaults).forEach((vault) => { + if (getHoldingsAliasVaultAddress(getVaultAddress(vault))) { + return + } + if (!shouldIncludeVault(vault)) { + return + } + const key = getVaultKey(vault) + if (vaultMap.has(key)) { + return + } + if (!checkHasRawHoldings(vault)) { + return + } + const isRetired = Boolean(getVaultInfo(vault)?.isRetired) + upsertVault(vault, { + isActive: !isRetired, + isRetired, + isMigratable: Boolean(getVaultMigration(vault)?.available), + isBypassedHolding: true + }) + }) + + return vaultMap + }, [isEnabled, isEnabled ? vaults : null, isEnabled ? allVaults : null, checkHasRawHoldings]) + + const walletFlags = useMemo(() => { + const flags = new Map() + vaultIndex.forEach((entry, key) => { + const hasRawHoldings = entry.isBypassedHolding ? checkHasRawHoldings(entry.vault) : false + flags.set(key, { + hasHoldings: hasRawHoldings || checkHasHoldings(entry.vault), + hasAvailableBalance: checkHasAvailableBalance(entry.vault) + }) + }) + return flags + }, [vaultIndex, checkHasHoldings, checkHasAvailableBalance, checkHasRawHoldings]) + + const holdingsVaults = useMemo(() => { + return Array.from(vaultIndex.values()) + .filter(({ key }) => walletFlags.get(key)?.hasHoldings) + .map(({ vault }) => vault) + }, [vaultIndex, walletFlags]) + + const availableVaults = useMemo(() => { + return Array.from(vaultIndex.values()) + .filter(({ key, isActive }) => { + const flags = walletFlags.get(key) + return Boolean(flags?.hasAvailableBalance && (isActive || flags?.hasHoldings)) + }) + .map(({ vault }) => vault) + }, [vaultIndex, walletFlags]) + + const filteredResults = useMemo(() => { + const filteredVaults: TKongVault[] = [] + const vaultFlags: Record = {} + const shouldShowHidden = Boolean(showHiddenVaults) + const hasChainFilter = Boolean(chains?.length) + const hasTypeFilter = Boolean(types?.length) + const hasCategoryFilter = Boolean(categories?.length) + const hasAggressivenessFilter = Boolean(aggressiveness?.length) + const hasUnderlyingAssetFilter = normalizedUnderlyingAssets.size > 0 + const availableUnderlyingAssets = new Set() + const underlyingAssetVaults: Record = {} + + const matchesSearch = (searchableText: string): boolean => { + if (!isSearchEnabled) { + return true + } + if (searchRegex) { + return searchRegex.test(searchableText) + } + return searchableText.includes(lowercaseSearch) + } + + vaultIndex.forEach((entry) => { + const { + key, + vault, + searchableText, + kind, + category, + aggressiveness: aggressivenessScore, + isHidden, + isActive, + isMigratable, + isRetired + } = entry + const walletFlag = walletFlags.get(key) + const hasHoldings = Boolean(walletFlag?.hasHoldings) + const isMigratableVault = Boolean(isMigratable && hasHoldings) + const isRetiredVault = Boolean(isRetired && hasHoldings) + const hasUserHoldings = hasHoldings || isMigratableVault || isRetiredVault + + if (!isActive && !hasHoldings) { + return + } + if (!shouldShowHidden && isHidden) { + return + } + + if (!matchesSearch(searchableText)) { + return + } + + if (!hasUserHoldings && hasChainFilter && !chains?.includes(getVaultChainID(vault))) { + return + } + + const vaultTvl = getVaultTVL(vault)?.tvl || 0 + if (!hasUserHoldings && vaultTvl < minTvlValue) { + return + } + + vaultFlags[key] = { + hasHoldings: hasUserHoldings, + isMigratable: isMigratableVault, + isRetired: isRetiredVault, + isHidden + } + + const matchesKind = hasUserHoldings || !hasTypeFilter || Boolean(types?.includes(kind)) + const matchesCategory = hasUserHoldings || !hasCategoryFilter || Boolean(categories?.includes(category)) + const matchesAggressiveness = + hasUserHoldings || + !hasAggressivenessFilter || + (aggressivenessScore !== null && Boolean(aggressiveness?.includes(aggressivenessScore))) + + if (matchesKind && matchesCategory && matchesAggressiveness) { + const assetKey = normalizeUnderlyingAssetSymbol(getVaultToken(vault)?.symbol) + if (assetKey && !underlyingAssetVaults[assetKey]) { + availableUnderlyingAssets.add(assetKey) + underlyingAssetVaults[assetKey] = vault + } else if (assetKey) { + availableUnderlyingAssets.add(assetKey) + } + + const matchesUnderlyingAsset = + hasUserHoldings || !hasUnderlyingAssetFilter || (assetKey && expandedUnderlyingAssets.has(assetKey)) + if (!matchesUnderlyingAsset) { + return + } + + filteredVaults.push(vault) + } + }) + + return { + filteredVaults, + vaultFlags, + availableUnderlyingAssets: Array.from(availableUnderlyingAssets), + underlyingAssetVaults + } + }, [ + vaultIndex, + walletFlags, types, chains, - search, categories, aggressiveness, - underlyingAssets, - minTvl, - showHiddenVaults, - enabled - }) + normalizedUnderlyingAssets, + expandedUnderlyingAssets, + minTvlValue, + searchRegex, + lowercaseSearch, + isSearchEnabled, + showHiddenVaults + ]) - return toV2Result(result) + return { + ...filteredResults, + holdingsVaults, + availableVaults, + isLoading: isEnabled ? isLoadingVaultList : false + } } diff --git a/src/components/shared/hooks/useV3VaultFilter.ts b/src/components/shared/hooks/useV3VaultFilter.ts index 43bda86a3..ad2763a1b 100644 --- a/src/components/shared/hooks/useV3VaultFilter.ts +++ b/src/components/shared/hooks/useV3VaultFilter.ts @@ -1,7 +1,74 @@ -import type { TVaultAggressiveness } from '@pages/vaults/utils/vaultListFacets' -import { type TVaultFilterResult, useVaultFilter } from './useVaultFilter' +import { useAppSettings } from '@pages/vaults/contexts/useAppSettings' +import { + getVaultAddress, + getVaultChainID, + getVaultInfo, + getVaultMigration, + getVaultName, + getVaultStaking, + getVaultSymbol, + getVaultToken, + getVaultTVL, + type TKongVault +} from '@pages/vaults/domain/kongVaultSelectors' +import { getHoldingsAliasVaultAddress } from '@pages/vaults/domain/normalizeVault' +import { DEFAULT_MIN_TVL } from '@pages/vaults/utils/constants' +import { + deriveAssetCategory, + deriveListKind, + deriveV3Aggressiveness, + expandUnderlyingAssetSelection, + isAllocatorVaultOverride, + normalizeUnderlyingAssetSymbol, + type TVaultAggressiveness +} from '@pages/vaults/utils/vaultListFacets' +import { useDeepCompareMemo } from '@react-hookz/web' +import { useWallet } from '@shared/contexts/useWallet' +import { useYearn } from '@shared/contexts/useYearn' +import { isZeroAddress } from '@shared/utils' +import { useMemo } from 'react' +import { + createCheckHasAvailableBalance, + createCheckHasHoldings, + getVaultKey, + isV3Vault, + type TVaultFlags +} from './useVaultFilterUtils' -type TV3VaultFilterResult = TVaultFilterResult +type TVaultIndexEntry = { + key: string + vault: TKongVault + searchableText: string + kind: ReturnType + category: string + aggressiveness: TVaultAggressiveness | null + isHidden: boolean + isFeatured: boolean + isActive: boolean + isMigratable: boolean + isRetired: boolean + isBypassedHolding: boolean +} + +type TVaultWalletFlags = { + hasHoldings: boolean + hasAvailableBalance: boolean +} + +type TV3VaultFilterResult = { + filteredVaults: TKongVault[] + holdingsVaults: TKongVault[] + availableVaults: TKongVault[] + vaultFlags: Record + availableUnderlyingAssets: string[] + underlyingAssetVaults: Record + totalMatchingVaults: number + totalHoldingsMatching: number + totalAvailableMatching: number + totalMigratableMatching: number + totalRetiredMatching: number + isLoading: boolean +} export function useV3VaultFilter( types: string[] | null, @@ -14,16 +81,336 @@ export function useV3VaultFilter( showHiddenVaults?: boolean, enabled?: boolean ): TV3VaultFilterResult { - return useVaultFilter({ - version: 'v3', + const { vaults, allVaults, getPrice, isLoadingVaultList } = useYearn() + const { getBalance } = useWallet() + const { shouldHideDust } = useAppSettings() + const isEnabled = enabled ?? true + const searchValue = search ?? '' + const minTvlValue = Number.isFinite(minTvl) ? Math.max(0, minTvl || 0) : DEFAULT_MIN_TVL + const isSearchEnabled = isEnabled && searchValue !== '' + const searchRegex = useMemo(() => { + if (!isSearchEnabled) { + return null + } + try { + const escapedSearch = searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(escapedSearch, 'i') + } catch { + return null + } + }, [isSearchEnabled, searchValue]) + const lowercaseSearch = useMemo( + () => (isSearchEnabled ? searchValue.toLowerCase() : ''), + [isSearchEnabled, searchValue] + ) + const normalizedUnderlyingAssets = useMemo(() => { + if (!underlyingAssets || underlyingAssets.length === 0) { + return new Set() + } + const normalized = underlyingAssets.map((asset) => normalizeUnderlyingAssetSymbol(asset)).filter(Boolean) + return new Set(normalized) + }, [underlyingAssets]) + const expandedUnderlyingAssets = useMemo( + () => expandUnderlyingAssetSelection(normalizedUnderlyingAssets), + [normalizedUnderlyingAssets] + ) + + const checkHasHoldings = useMemo( + () => createCheckHasHoldings(getBalance, getPrice, shouldHideDust), + [getBalance, getPrice, shouldHideDust] + ) + + const checkHasAvailableBalance = useMemo(() => createCheckHasAvailableBalance(getBalance), [getBalance]) + const checkHasRawHoldings = useMemo( + () => + (vault: TKongVault): boolean => { + const chainID = getVaultChainID(vault) + const vaultBalance = getBalance({ + address: getVaultAddress(vault), + chainID + }) + if (vaultBalance.raw > 0n) { + return true + } + + const staking = getVaultStaking(vault) + if (isZeroAddress(staking.address)) { + return false + } + + const stakingBalance = getBalance({ + address: staking.address, + chainID + }) + return stakingBalance.raw > 0n + }, + [getBalance] + ) + + const vaultIndex = useDeepCompareMemo(() => { + if (!isEnabled) { + return new Map() + } + const vaultMap = new Map() + + const shouldIncludeVault = (vault: TKongVault): boolean => isV3Vault(vault, isAllocatorVaultOverride(vault)) + + const upsertVault = ( + vault: TKongVault, + updates: Partial> + ): void => { + const key = getVaultKey(vault) + const existing = vaultMap.get(key) + if (existing) { + vaultMap.set(key, { ...existing, ...updates }) + return + } + + const token = getVaultToken(vault) + const info = getVaultInfo(vault) + const kind = deriveListKind(vault) + vaultMap.set(key, { + key, + vault, + searchableText: + `${getVaultName(vault)} ${getVaultSymbol(vault)} ${token.name} ${token.symbol} ${getVaultAddress(vault)} ${token.address}`.toLowerCase(), + kind, + category: deriveAssetCategory(vault), + aggressiveness: deriveV3Aggressiveness(vault), + isHidden: Boolean(info?.isHidden), + isFeatured: Boolean(info?.isHighlighted), + isActive: Boolean(updates.isActive), + isMigratable: Boolean(updates.isMigratable), + isRetired: Boolean(updates.isRetired), + isBypassedHolding: Boolean(updates.isBypassedHolding) + }) + } + + Object.values(vaults).forEach((vault) => { + if (getHoldingsAliasVaultAddress(getVaultAddress(vault))) { + return + } + if (!shouldIncludeVault(vault)) { + return + } + const isRetired = Boolean(getVaultInfo(vault)?.isRetired) + upsertVault(vault, { + isActive: !isRetired, + isRetired, + isMigratable: Boolean(getVaultMigration(vault)?.available) + }) + }) + + Object.values(allVaults).forEach((vault) => { + if (getHoldingsAliasVaultAddress(getVaultAddress(vault))) { + return + } + if (!shouldIncludeVault(vault)) { + return + } + const key = getVaultKey(vault) + if (vaultMap.has(key)) { + return + } + if (!checkHasRawHoldings(vault)) { + return + } + const isRetired = Boolean(getVaultInfo(vault)?.isRetired) + upsertVault(vault, { + isActive: !isRetired, + isRetired, + isMigratable: Boolean(getVaultMigration(vault)?.available), + isBypassedHolding: true + }) + }) + + return vaultMap + }, [isEnabled, isEnabled ? vaults : null, isEnabled ? allVaults : null, checkHasRawHoldings]) + + const walletFlags = useMemo(() => { + const flags = new Map() + vaultIndex.forEach((entry, key) => { + const hasRawHoldings = entry.isBypassedHolding ? checkHasRawHoldings(entry.vault) : false + flags.set(key, { + hasHoldings: hasRawHoldings || checkHasHoldings(entry.vault), + hasAvailableBalance: checkHasAvailableBalance(entry.vault) + }) + }) + return flags + }, [vaultIndex, checkHasHoldings, checkHasAvailableBalance, checkHasRawHoldings]) + + const holdingsVaults = useMemo(() => { + return Array.from(vaultIndex.values()) + .filter(({ key }) => walletFlags.get(key)?.hasHoldings) + .map(({ vault }) => vault) + }, [vaultIndex, walletFlags]) + + const availableVaults = useMemo(() => { + return Array.from(vaultIndex.values()) + .filter(({ key, isActive }) => { + const flags = walletFlags.get(key) + return Boolean(flags?.hasAvailableBalance && (isActive || flags?.hasHoldings)) + }) + .map(({ vault }) => vault) + }, [vaultIndex, walletFlags]) + + const filteredResults = useMemo(() => { + const filteredVaults: TKongVault[] = [] + const vaultFlags: Record = {} + + let totalMatchingVaults = 0 + let totalHoldingsMatching = 0 + let totalAvailableMatching = 0 + let totalMigratableMatching = 0 + let totalRetiredMatching = 0 + const availableUnderlyingAssets = new Set() + const underlyingAssetVaults: Record = {} + 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 => { + if (!isSearchEnabled) { + return true + } + if (searchRegex) { + return searchRegex.test(searchableText) + } + return searchableText.includes(lowercaseSearch) + } + + vaultIndex.forEach((entry) => { + const { + key, + vault, + searchableText, + kind, + category, + aggressiveness: aggressivenessScore, + isHidden, + isFeatured, + isActive, + isMigratable, + isRetired + } = entry + const walletFlag = walletFlags.get(key) + const hasHoldings = Boolean(walletFlag?.hasHoldings) + const hasAvailableBalance = Boolean(walletFlag?.hasAvailableBalance) + const isMigratableVault = Boolean(isMigratable && hasHoldings) + const isRetiredVault = Boolean(isRetired && hasHoldings) + const hasUserHoldings = hasHoldings || isMigratableVault || isRetiredVault + + if (!isActive && !hasHoldings) { + return + } + if (!showHiddenVaults && isHidden) { + return + } + if (!matchesSearch(searchableText)) { + return + } + + if (!hasUserHoldings && hasChainFilter && !chains?.includes(getVaultChainID(vault))) { + return + } + + const vaultTvl = getVaultTVL(vault)?.tvl || 0 + if (!hasUserHoldings && vaultTvl < minTvlValue) { + return + } + + vaultFlags[key] = { + hasHoldings: hasUserHoldings, + isMigratable: isMigratableVault, + isRetired: isRetiredVault, + isHidden + } + + totalMatchingVaults++ + if (hasUserHoldings) { + totalHoldingsMatching++ + } + if (hasAvailableBalance) { + totalAvailableMatching++ + } + if (isMigratableVault) { + totalMigratableMatching++ + } + if (isRetiredVault) { + totalRetiredMatching++ + } + + 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 shouldIncludeByAggressiveness = + hasUserHoldings || + !hasAggressivenessFilter || + (aggressivenessScore !== null && Boolean(aggressiveness?.includes(aggressivenessScore))) + + if ( + shouldIncludeByCategory && + shouldIncludeByFeaturedGate && + shouldIncludeByKind && + shouldIncludeByAggressiveness + ) { + const assetKey = normalizeUnderlyingAssetSymbol(getVaultToken(vault)?.symbol) + if (assetKey && !underlyingAssetVaults[assetKey]) { + availableUnderlyingAssets.add(assetKey) + underlyingAssetVaults[assetKey] = vault + } else if (assetKey) { + availableUnderlyingAssets.add(assetKey) + } + + const matchesUnderlyingAsset = + hasUserHoldings || !hasUnderlyingAssetFilter || (assetKey && expandedUnderlyingAssets.has(assetKey)) + + if (matchesUnderlyingAsset) { + filteredVaults.push(vault) + } + } + }) + + return { + filteredVaults, + holdingsVaults, + vaultFlags, + availableUnderlyingAssets: Array.from(availableUnderlyingAssets), + underlyingAssetVaults, + totalMatchingVaults, + totalHoldingsMatching, + totalAvailableMatching, + totalMigratableMatching, + totalRetiredMatching + } + }, [ + vaultIndex, + walletFlags, types, chains, - search, categories, aggressiveness, - underlyingAssets, - minTvl, + normalizedUnderlyingAssets, + expandedUnderlyingAssets, + minTvlValue, + holdingsVaults, showHiddenVaults, - enabled - }) + searchRegex, + lowercaseSearch, + isSearchEnabled + ]) + + return { + ...filteredResults, + availableVaults, + isLoading: isEnabled ? isLoadingVaultList : false + } } diff --git a/src/components/shared/hooks/useVaultFilterUtils.test.ts b/src/components/shared/hooks/useVaultFilterUtils.test.ts new file mode 100644 index 000000000..e81cfb487 --- /dev/null +++ b/src/components/shared/hooks/useVaultFilterUtils.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { getVaultHoldingsUsdValue } from './useVaultFilterUtils' + +const VAULT_ADDRESS = '0x8589462548984c5C0f2C0140FB276351B5a77fe1' +const ASSET_ADDRESS = '0x0000000000000000000000000000000000000002' + +function makeStrategyVault() { + return { + chainId: 1, + address: VAULT_ADDRESS, + name: 'Strategy Vault', + symbol: 'yvSTRAT', + apiVersion: '3.0.0', + decimals: 18, + asset: { + address: ASSET_ADDRESS, + name: 'USD Asset', + 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: [] + }, + pricePerShare: '1050000' + } as any +} + +describe('getVaultHoldingsUsdValue', () => { + it('values list-only holdings from list pricePerShare when share price is unavailable', () => { + const vault = makeStrategyVault() + const value = getVaultHoldingsUsdValue( + vault, + ({ address }) => ({ value: address.toLowerCase() === VAULT_ADDRESS.toLowerCase() ? 0 : undefined }), + ({ address }) => ({ + raw: address.toLowerCase() === VAULT_ADDRESS.toLowerCase() ? 2n * 10n ** 18n : 0n, + normalized: address.toLowerCase() === VAULT_ADDRESS.toLowerCase() ? 2 : 0, + display: address.toLowerCase() === VAULT_ADDRESS.toLowerCase() ? '2' : '0', + decimals: 18 + }), + ({ address }) => ({ + normalized: address.toLowerCase() === ASSET_ADDRESS.toLowerCase() ? 1 : 0 + }) + ) + + expect(value).toBeCloseTo(2.1, 8) + }) +}) diff --git a/src/components/shared/hooks/useVaultFilterUtils.ts b/src/components/shared/hooks/useVaultFilterUtils.ts index fa5d13c51..68f34e3dd 100644 --- a/src/components/shared/hooks/useVaultFilterUtils.ts +++ b/src/components/shared/hooks/useVaultFilterUtils.ts @@ -1,22 +1,28 @@ import { getVaultAddress, + getVaultAPR, getVaultChainID, + getVaultDecimals, getVaultName, getVaultStaking, getVaultSymbol, getVaultToken, + getVaultTVL, getVaultVersion, type TKongVault, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { getNativeTokenWrapperContract } from '@pages/vaults/utils/nativeTokens' +import type { TDict } from '@shared/types' import type { TAddress } from '@shared/types/address' import type { TNormalizedBN } from '@shared/types/mixed' -import { toAddress } from '@shared/utils' +import { isZeroAddress, toAddress, toNormalizedBN } from '@shared/utils' import { ETH_TOKEN_ADDRESS } from '@shared/utils/constants' +type TVaultLike = TKongVaultInput + export type TVaultWithMetadata = { - vault: TKongVault + vault: TVaultLike hasHoldings: boolean hasAvailableBalance: boolean isHoldingsVault: boolean @@ -29,42 +35,67 @@ export type TVaultFlags = { isMigratable: boolean isRetired: boolean isHidden: boolean + isNotYearn?: boolean } type TTokenAndChain = { address: TAddress; chainID: number } type TBalanceGetter = (params: TTokenAndChain) => TNormalizedBN type TPriceGetter = (params: TTokenAndChain) => { normalized: number } +type TTokenGetter = (params: TTokenAndChain) => { value?: number } +type TStakingConversionMap = Record + +type TVaultHoldingsUsdOptions = { + allVaults?: TDict + stakingConvertedAssets?: TStakingConversionMap +} + +const zeroNormalizedBalance = toNormalizedBN(0n, 18) + +const getVaultSharePriceUsd = (vault: TVaultLike, getPrice: TPriceGetter): number => { + const chainID = getVaultChainID(vault) + const vaultAddress = getVaultAddress(vault) + const directSharePrice = getPrice({ address: vaultAddress, chainID }).normalized + if (directSharePrice > 0) { + return directSharePrice + } + + const assetToken = getVaultToken(vault) + const assetPrice = getPrice({ address: assetToken.address, chainID }).normalized + const pricePerShare = getVaultAPR(vault).pricePerShare.today + if (assetPrice > 0 && pricePerShare > 0) { + return assetPrice * pricePerShare + } + + return getVaultTVL(vault).price +} export function createCheckHasHoldings( getBalance: TBalanceGetter, getPrice: TPriceGetter, shouldHideDust: boolean -): (vault: TKongVaultInput) => boolean { - return function checkHasHoldings(vault: TKongVaultInput): boolean { +): (vault: TVaultLike) => boolean { + return function checkHasHoldings(vault: TVaultLike): boolean { const address = getVaultAddress(vault) const chainID = getVaultChainID(vault) + const vaultDecimals = getVaultDecimals(vault) const staking = getVaultStaking(vault) const vaultBalance = getBalance({ address, chainID }) const hasVaultBalance = vaultBalance.raw > 0n - let vaultPrice: { normalized: number } | null = null - - const getVaultPrice = (): { normalized: number } => { - if (!vaultPrice) { - vaultPrice = getPrice({ address, chainID }) - } - return vaultPrice - } + const sharePriceUsd = getVaultSharePriceUsd(vault, getPrice) - if (staking.available) { + if (!isZeroAddress(staking.address)) { const stakingBalance = getBalance({ address: staking.address, chainID }) const hasValidStakedBalance = stakingBalance.raw > 0n if (hasValidStakedBalance) { - const price = getVaultPrice() - const stakedBalanceValue = Number(stakingBalance.normalized) * price.normalized + if (sharePriceUsd <= 0) { + return true + } + const stakedBalance = toNormalizedBN(stakingBalance.raw, vaultDecimals).normalized + const stakedBalanceValue = stakedBalance * sharePriceUsd if (!(shouldHideDust && stakedBalanceValue < 0.01)) { return true } @@ -75,15 +106,17 @@ export function createCheckHasHoldings( return false } - const price = getVaultPrice() - const balanceValue = Number(vaultBalance.normalized) * price.normalized + if (sharePriceUsd <= 0) { + return true + } + const balanceValue = toNormalizedBN(vaultBalance.raw, vaultDecimals).normalized * sharePriceUsd return !(shouldHideDust && balanceValue < 0.01) } } -export function createCheckHasAvailableBalance(getBalance: TBalanceGetter): (vault: TKongVaultInput) => boolean { - return function checkHasAvailableBalance(vault: TKongVaultInput): boolean { +export function createCheckHasAvailableBalance(getBalance: TBalanceGetter): (vault: TVaultLike) => boolean { + return function checkHasAvailableBalance(vault: TVaultLike): boolean { const token = getVaultToken(vault) const chainID = getVaultChainID(vault) const wantBalance = getBalance({ address: token.address, chainID }) @@ -103,11 +136,98 @@ export function createCheckHasAvailableBalance(getBalance: TBalanceGetter): (vau } } -export function getVaultKey(vault: TKongVaultInput): string { +export function getVaultHoldingsUsdValue( + vault: TVaultLike, + getToken: TTokenGetter, + getBalance: TBalanceGetter, + getPrice: TPriceGetter, + options?: TVaultHoldingsUsdOptions +): number { + const chainID = getVaultChainID(vault) + const address = getVaultAddress(vault) + const staking = getVaultStaking(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 resolvePositionValue = (positionVault: TVaultLike, directValue: number, shares: number): number => { + if (Number.isFinite(directValue) && directValue > 0) { + return directValue + } + if (!Number.isFinite(shares) || shares <= 0) { + return 0 + } + const positionChainID = getVaultChainID(positionVault) + const positionAddress = getVaultAddress(positionVault) + const positionToken = getVaultToken(positionVault) + const vaultSharePrice = Number(getPrice({ address: positionAddress, chainID: positionChainID }).normalized || 0) + const pricePerShare = Number(getVaultAPR(positionVault).pricePerShare.today || 0) + const resolvedAssetPrice = Number( + getPrice({ address: positionToken.address, chainID: positionChainID }).normalized || 0 + ) + const assetPrice = resolvedAssetPrice > 0 ? resolvedAssetPrice : Number(getVaultTVL(positionVault).price || 0) + + if (Number.isFinite(vaultSharePrice) && vaultSharePrice > 0) { + const viaVaultPrice = shares * vaultSharePrice + if (Number.isFinite(viaVaultPrice)) { + return viaVaultPrice + } + } + if (Number.isFinite(pricePerShare) && pricePerShare > 0 && Number.isFinite(assetPrice) && assetPrice > 0) { + const viaPps = shares * pricePerShare * assetPrice + if (Number.isFinite(viaPps)) { + return viaPps + } + } + return 0 + } + + const resolveStakingValue = (): number => { + if (!canUseStaking) { + return 0 + } + + if (Number.isFinite(stakingDirectValue) && stakingDirectValue > 0) { + return stakingDirectValue + } + + if (stakingVault) { + return resolvePositionValue(stakingVault, 0, stakingShares) + } + + if (convertedStakingAssets !== undefined && convertedStakingAssets > 0n) { + const convertedShares = toNormalizedBN(convertedStakingAssets, getVaultDecimals(vault)).normalized + return resolvePositionValue(vault, 0, convertedShares) + } + + return resolvePositionValue(vault, 0, stakingShares) + } + + const totalValue = resolvePositionValue(vault, vaultDirectValue, vaultShares) + resolveStakingValue() + if (!Number.isFinite(totalValue)) { + return 0 + } + return totalValue +} + +export function getVaultKey(vault: TVaultLike): string { return `${getVaultChainID(vault)}_${toAddress(getVaultAddress(vault))}` } -export function matchesSearch(vault: TKongVaultInput, search: string): boolean { +export function matchesSearch(vault: TVaultLike, search: string): boolean { const token = getVaultToken(vault) const searchableText = `${getVaultName(vault)} ${getVaultSymbol(vault)} ${token.name} ${token.symbol} ${getVaultAddress(vault)} ${token.address}` @@ -121,7 +241,7 @@ export function matchesSearch(vault: TKongVaultInput, search: string): boolean { } } -export function isV3Vault(vault: TKongVaultInput, isAllocatorOverride: boolean): boolean { +export function isV3Vault(vault: TVaultLike, isAllocatorOverride: boolean): boolean { const version = getVaultVersion(vault) return version.startsWith('3') || version.startsWith('~3') || isAllocatorOverride } @@ -129,11 +249,11 @@ export function isV3Vault(vault: TKongVaultInput, isAllocatorOverride: boolean): export function extractHoldingsVaults(vaultMap: Map): TKongVault[] { return Array.from(vaultMap.values()) .filter(({ hasHoldings }) => hasHoldings) - .map(({ vault }) => vault) + .map(({ vault }) => vault as TKongVault) } export function extractAvailableVaults(vaultMap: Map): TKongVault[] { return Array.from(vaultMap.values()) .filter(({ hasAvailableBalance }) => hasAvailableBalance) - .map(({ vault }) => vault) + .map(({ vault }) => vault as TKongVault) } diff --git a/src/components/shared/utils/format.ts b/src/components/shared/utils/format.ts index 4c658b67f..dd02357d3 100755 --- a/src/components/shared/utils/format.ts +++ b/src/components/shared/utils/format.ts @@ -80,7 +80,27 @@ export const exactToSimple = (bn?: bigint | string | number, scale?: number) => ** to correctly format bigNumbers, currency and date **************************************************************************/ export const toBigInt = (amount?: TNumberish): bigint => { - return BigInt(amount || 0) + if (amount === undefined || amount === null) { + return 0n + } + + if (typeof amount === 'bigint') { + return amount + } + + const asString = String(amount).trim() + if (asString === '') { + return 0n + } + + const normalized = asString.includes('e') || asString.includes('E') ? eToNumber(asString) : asString + const integerPart = normalized.includes('.') ? normalized.split('.')[0] : normalized + + if (integerPart === '' || integerPart === '-' || integerPart === '+') { + return 0n + } + + return BigInt(integerPart) } export function toBigNumberAsAmount(bnAmount = 0n, decimals = 18, decimalsToDisplay = 2, symbol = ''): string { diff --git a/src/components/shared/utils/schemas/kongVaultListSchema.ts b/src/components/shared/utils/schemas/kongVaultListSchema.ts index aed235680..82f43c293 100644 --- a/src/components/shared/utils/schemas/kongVaultListSchema.ts +++ b/src/components/shared/utils/schemas/kongVaultListSchema.ts @@ -6,6 +6,23 @@ const coerceNullableNumber = z.preprocess( (val) => (val === null || val === undefined ? null : Number(val)), z.number().nullable() ) +const coerceNullableBigNumberish = z + .union([z.number(), z.string(), z.null()]) + .transform((value) => (value === null ? null : String(value))) + +const stakingRewardSchema = z + .object({ + address: addressSchema.optional().catch('0x0000000000000000000000000000000000000000'), + name: z.string().optional().default('').catch(''), + symbol: z.string().optional().default('').catch(''), + decimals: z.number().optional().default(18).catch(18), + price: coerceNullableNumber.optional().catch(null), + isFinished: z.boolean().optional().default(false).catch(false), + finishedAt: coerceNullableNumber.optional().catch(null), + apr: coerceNullableNumber.optional().catch(null), + perWeek: coerceNullableNumber.optional().catch(null) + }) + .passthrough() export const kongVaultListItemSchema = z.object({ chainId: z.number(), @@ -102,12 +119,17 @@ export const kongVaultListItemSchema = z.object({ staking: z .object({ address: addressSchema.nullable(), - available: z.boolean() + available: z.boolean(), + source: z.string().optional().default('').catch(''), + rewards: z.array(stakingRewardSchema).optional().default([]).catch([]) }) - .nullish() + .nullish(), + + pricePerShare: coerceNullableBigNumberish.optional() }) export const kongVaultListSchema = z.array(kongVaultListItemSchema) export type TKongVaultListItem = z.infer export type TKongVaultList = z.infer +export type TKongVaultListItemStakingReward = z.infer diff --git a/src/components/shared/utils/vaultApy.test.ts b/src/components/shared/utils/vaultApy.test.ts index 824d6e9a3..f4036247c 100644 --- a/src/components/shared/utils/vaultApy.test.ts +++ b/src/components/shared/utils/vaultApy.test.ts @@ -53,7 +53,9 @@ const BASE_VAULT: TKongVault = { riskLevel: 1, staking: { address: null, - available: false + available: false, + source: '', + rewards: [] } }