Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/components/pages/portfolio/hooks/portfolioVisibility.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
9 changes: 9 additions & 0 deletions src/components/pages/portfolio/hooks/portfolioVisibility.ts
Original file line number Diff line number Diff line change
@@ -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))
}
169 changes: 88 additions & 81 deletions src/components/pages/portfolio/hooks/usePortfolioModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<TPossibleSortBy>('deposited')
const [sortDirection, setSortDirection] = useState<TSortDirection>('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<string, TKongVault>()

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<string>()

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<string>()
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<string, TVaultFlags>,
[holdingsVaults]
const visibleHoldingsVaults = useMemo(
() => filterVisiblePortfolioHoldings(holdingsVaults, showHiddenVaults),
[holdingsVaults, showHiddenVaults]
)

const vaultFlags = useMemo(() => {
const flags: Record<string, TVaultFlags> = {}

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
Expand All @@ -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])
Expand Down Expand Up @@ -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
},
[]
)
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/components/pages/portfolio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -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<number | null>(null)
Expand Down
Loading
Loading