From bfdff39e0d8d901d399fc9aeea27e0ef3ba0bbee Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 12 Mar 2026 11:32:13 -0400 Subject: [PATCH] Fix portfolio vault suggestion ranking Correct the preferred vault selection and external suggestion\ndeduplication on the release branch.\n\n- choose the highest-TVL qualifying vault instead of the smallest\n- dedupe external matches before truncating to two suggestions\n- add focused tests for both regression paths\n\nThis restores the intended ranking behavior for portfolio suggestions\nand avoids wasting external slots on duplicate vault matches. --- .../hooks/buildVaultSuggestions.test.ts | 138 ++++++++++++++++++ .../portfolio/hooks/buildVaultSuggestions.ts | 51 +++++++ .../portfolio/hooks/getEligibleVaults.test.ts | 85 +++++++++++ .../portfolio/hooks/getEligibleVaults.ts | 2 +- .../portfolio/hooks/useVaultSuggestions.ts | 34 +---- 5 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts create mode 100644 src/components/pages/portfolio/hooks/buildVaultSuggestions.ts create mode 100644 src/components/pages/portfolio/hooks/getEligibleVaults.test.ts diff --git a/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts b/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts new file mode 100644 index 000000000..43e738642 --- /dev/null +++ b/src/components/pages/portfolio/hooks/buildVaultSuggestions.test.ts @@ -0,0 +1,138 @@ +import type { TExternalToken } from '@pages/portfolio/constants/externalTokens' +import { buildVaultSuggestions } from '@pages/portfolio/hooks/buildVaultSuggestions' +import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { describe, expect, it } from 'vitest' + +const buildVault = ({ + address, + assetSymbol, + tvl, + apr +}: { + address: string + assetSymbol: string + tvl: number + apr: number +}): TKongVault => + ({ + chainId: 1, + address, + name: `${assetSymbol} Vault`, + symbol: `yv${assetSymbol}`, + apiVersion: '3.0.0', + decimals: 18, + asset: { + address: + assetSymbol === 'USDC' + ? '0x0000000000000000000000000000000000000010' + : assetSymbol === 'DAI' + ? '0x0000000000000000000000000000000000000011' + : '0x0000000000000000000000000000000000000012', + name: assetSymbol, + symbol: assetSymbol, + decimals: 18 + }, + tvl, + performance: { + oracle: { apr, apy: apr }, + estimated: { + apr, + apy: apr, + type: 'estimated', + components: {} + }, + historical: { + net: apr, + weeklyNet: apr, + monthlyNet: apr, + inceptionNet: apr + } + }, + fees: { + managementFee: 0.0025, + performanceFee: 0.1 + }, + category: 'Stablecoin', + type: 'Standard', + kind: 'Multi Strategy', + v3: true, + yearn: true, + isRetired: false, + isHidden: false, + isBoosted: false, + isHighlighted: true, + strategiesCount: 1, + riskLevel: 1, + staking: { + address: null, + available: false + } + }) as unknown as TKongVault + +describe('buildVaultSuggestions', () => { + it('dedupes repeated vault matches before applying the two-item cap', () => { + const usdcVault = buildVault({ + address: '0x0000000000000000000000000000000000000001', + assetSymbol: 'USDC', + tvl: 2_000_000, + apr: 0.06 + }) + const daiVault = buildVault({ + address: '0x0000000000000000000000000000000000000002', + assetSymbol: 'DAI', + tvl: 1_500_000, + apr: 0.05 + }) + const wethVault = buildVault({ + address: '0x0000000000000000000000000000000000000003', + assetSymbol: 'WETH', + tvl: 1_250_000, + apr: 0.05 + }) + + const detectedTokens: TExternalToken[] = [ + { + address: '0x0000000000000000000000000000000000000101', + chainId: 1, + protocol: 'Aave V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0x0000000000000000000000000000000000000010' + }, + { + address: '0x0000000000000000000000000000000000000102', + chainId: 1, + protocol: 'Compound V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0x0000000000000000000000000000000000000010' + }, + { + address: '0x0000000000000000000000000000000000000103', + chainId: 1, + protocol: 'Spark', + underlyingSymbol: 'DAI', + underlyingAddress: '0x0000000000000000000000000000000000000011' + }, + { + address: '0x0000000000000000000000000000000000000104', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'WETH', + underlyingAddress: '0x0000000000000000000000000000000000000012' + } + ] + + const suggestions = buildVaultSuggestions( + detectedTokens, + { + [usdcVault.address]: usdcVault, + [daiVault.address]: daiVault, + [wethVault.address]: wethVault + }, + new Set() + ) + + expect(suggestions).toHaveLength(2) + expect(suggestions[0]?.vault).toBe(usdcVault) + expect(suggestions[1]?.vault).toBe(daiVault) + }) +}) diff --git a/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts b/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts new file mode 100644 index 000000000..acc26ac9f --- /dev/null +++ b/src/components/pages/portfolio/hooks/buildVaultSuggestions.ts @@ -0,0 +1,51 @@ +import type { TExternalToken } from '@pages/portfolio/constants/externalTokens' +import { getEligibleVaults, normalizeSymbol, selectPreferredVault } from '@pages/portfolio/hooks/getEligibleVaults' +import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' + +export type TVaultSuggestion = { + vault: TKongVault + externalProtocol: string + underlyingSymbol: string +} + +export function buildVaultSuggestions( + detectedTokens: TExternalToken[], + vaults: Record, + holdingsKeySet: Set +): TVaultSuggestion[] { + if (detectedTokens.length === 0) return [] + + const eligible = getEligibleVaults(vaults, holdingsKeySet) + + const vaultsBySymbol = eligible.reduce((acc, vault) => { + const normalized = normalizeSymbol(getVaultToken(vault).symbol ?? '') + return acc.set(normalized, [...(acc.get(normalized) ?? []), vault]) + }, new Map()) + + const bestVaultByUnderlying = new Map( + [...vaultsBySymbol.entries()] + .map(([symbol, candidates]) => [symbol, selectPreferredVault(candidates)] as const) + .filter((entry): entry is [string, TKongVault] => entry[1] !== undefined) + ) + + const seenVaults = new Set() + + return detectedTokens + .flatMap((token) => { + const normalized = normalizeSymbol(token.underlyingSymbol) + const bestVault = bestVaultByUnderlying.get(normalized) + if (!bestVault) return [] + + return [{ vault: bestVault, externalProtocol: token.protocol, underlyingSymbol: token.underlyingSymbol }] + }) + .filter((suggestion) => { + const vaultKey = getVaultKey(suggestion.vault) + if (seenVaults.has(vaultKey)) { + return false + } + seenVaults.add(vaultKey) + return true + }) + .slice(0, 2) +} diff --git a/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts b/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts new file mode 100644 index 000000000..b4f2f5a35 --- /dev/null +++ b/src/components/pages/portfolio/hooks/getEligibleVaults.test.ts @@ -0,0 +1,85 @@ +import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { describe, expect, it } from 'vitest' +import { selectPreferredVault } from './getEligibleVaults' + +const buildVault = ({ + address, + assetSymbol, + tvl, + apr, + version = '3.0.0' +}: { + address: string + assetSymbol: string + tvl: number + apr: number + version?: string +}): TKongVault => + ({ + chainId: 1, + address, + name: `${assetSymbol} Vault`, + symbol: `yv${assetSymbol}`, + apiVersion: version, + decimals: 18, + asset: { + address: '0x0000000000000000000000000000000000000010', + name: assetSymbol, + symbol: assetSymbol, + decimals: 18 + }, + tvl, + performance: { + oracle: { apr, apy: apr }, + estimated: { + apr, + apy: apr, + type: 'estimated', + components: {} + }, + historical: { + net: apr, + weeklyNet: apr, + monthlyNet: apr, + inceptionNet: apr + } + }, + 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: true, + strategiesCount: 1, + riskLevel: 1, + staking: { + address: null, + available: false + } + }) as unknown as TKongVault + +describe('selectPreferredVault', () => { + it('prefers the highest-TVL qualifying vault', () => { + const smaller = buildVault({ + address: '0x0000000000000000000000000000000000000001', + assetSymbol: 'USDC', + tvl: 900_000, + apr: 0.06 + }) + const larger = buildVault({ + address: '0x0000000000000000000000000000000000000002', + assetSymbol: 'USDC', + tvl: 2_500_000, + apr: 0.05 + }) + + expect(selectPreferredVault([smaller, larger])).toBe(larger) + }) +}) diff --git a/src/components/pages/portfolio/hooks/getEligibleVaults.ts b/src/components/pages/portfolio/hooks/getEligibleVaults.ts index a0e16f49e..ef667f6f5 100644 --- a/src/components/pages/portfolio/hooks/getEligibleVaults.ts +++ b/src/components/pages/portfolio/hooks/getEligibleVaults.ts @@ -32,7 +32,7 @@ export function selectPreferredVault(candidates: TKongVault[]): TKongVault | und if (qualifying.length > 0) { return qualifying.reduce((best, vault) => - (getVaultTVL(vault).tvl ?? 0) < (getVaultTVL(best).tvl ?? 0) ? vault : best + (getVaultTVL(vault).tvl ?? 0) > (getVaultTVL(best).tvl ?? 0) ? vault : best ) } diff --git a/src/components/pages/portfolio/hooks/useVaultSuggestions.ts b/src/components/pages/portfolio/hooks/useVaultSuggestions.ts index 8ce737338..21b57ae29 100644 --- a/src/components/pages/portfolio/hooks/useVaultSuggestions.ts +++ b/src/components/pages/portfolio/hooks/useVaultSuggestions.ts @@ -1,18 +1,11 @@ import { EXTERNAL_TOKENS } from '@pages/portfolio/constants/externalTokens' -import { getEligibleVaults, normalizeSymbol, selectPreferredVault } from '@pages/portfolio/hooks/getEligibleVaults' -import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { buildVaultSuggestions, type TVaultSuggestion } from '@pages/portfolio/hooks/buildVaultSuggestions' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' import { useEnsoBalances } from '@shared/hooks/useEnsoBalances' import { toAddress } from '@shared/utils' import { useMemo } from 'react' -export type TVaultSuggestion = { - vault: TKongVault - externalProtocol: string - underlyingSymbol: string -} - export function useVaultSuggestions(holdingsKeySet: Set): { suggestions: TVaultSuggestion[] } { @@ -30,30 +23,7 @@ export function useVaultSuggestions(holdingsKeySet: Set): { ) const suggestions = useMemo(() => { - if (detectedTokens.length === 0) return [] - - const eligible = getEligibleVaults(vaults, holdingsKeySet) - - const vaultsBySymbol = eligible.reduce((acc, vault) => { - const normalized = normalizeSymbol(getVaultToken(vault).symbol ?? '') - return acc.set(normalized, [...(acc.get(normalized) ?? []), vault]) - }, new Map()) - - const bestVaultByUnderlying = new Map( - [...vaultsBySymbol.entries()] - .map(([symbol, candidates]) => [symbol, selectPreferredVault(candidates)] as const) - .filter((entry): entry is [string, TKongVault] => entry[1] !== undefined) - ) - - return detectedTokens - .flatMap((token) => { - const normalized = normalizeSymbol(token.underlyingSymbol) - const bestVault = bestVaultByUnderlying.get(normalized) - if (!bestVault) return [] - - return [{ vault: bestVault, externalProtocol: token.protocol, underlyingSymbol: token.underlyingSymbol }] - }) - .slice(0, 2) + return buildVaultSuggestions(detectedTokens, vaults, holdingsKeySet) }, [detectedTokens, vaults, holdingsKeySet]) return { suggestions }