Skip to content

Commit a1c981c

Browse files
authored
chore: fix portfolio vault suggestion ranking (#1105)
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.
1 parent 285e78d commit a1c981c

File tree

5 files changed

+277
-33
lines changed

5 files changed

+277
-33
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { TExternalToken } from '@pages/portfolio/constants/externalTokens'
2+
import { buildVaultSuggestions } from '@pages/portfolio/hooks/buildVaultSuggestions'
3+
import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors'
4+
import { describe, expect, it } from 'vitest'
5+
6+
const buildVault = ({
7+
address,
8+
assetSymbol,
9+
tvl,
10+
apr
11+
}: {
12+
address: string
13+
assetSymbol: string
14+
tvl: number
15+
apr: number
16+
}): TKongVault =>
17+
({
18+
chainId: 1,
19+
address,
20+
name: `${assetSymbol} Vault`,
21+
symbol: `yv${assetSymbol}`,
22+
apiVersion: '3.0.0',
23+
decimals: 18,
24+
asset: {
25+
address:
26+
assetSymbol === 'USDC'
27+
? '0x0000000000000000000000000000000000000010'
28+
: assetSymbol === 'DAI'
29+
? '0x0000000000000000000000000000000000000011'
30+
: '0x0000000000000000000000000000000000000012',
31+
name: assetSymbol,
32+
symbol: assetSymbol,
33+
decimals: 18
34+
},
35+
tvl,
36+
performance: {
37+
oracle: { apr, apy: apr },
38+
estimated: {
39+
apr,
40+
apy: apr,
41+
type: 'estimated',
42+
components: {}
43+
},
44+
historical: {
45+
net: apr,
46+
weeklyNet: apr,
47+
monthlyNet: apr,
48+
inceptionNet: apr
49+
}
50+
},
51+
fees: {
52+
managementFee: 0.0025,
53+
performanceFee: 0.1
54+
},
55+
category: 'Stablecoin',
56+
type: 'Standard',
57+
kind: 'Multi Strategy',
58+
v3: true,
59+
yearn: true,
60+
isRetired: false,
61+
isHidden: false,
62+
isBoosted: false,
63+
isHighlighted: true,
64+
strategiesCount: 1,
65+
riskLevel: 1,
66+
staking: {
67+
address: null,
68+
available: false
69+
}
70+
}) as unknown as TKongVault
71+
72+
describe('buildVaultSuggestions', () => {
73+
it('dedupes repeated vault matches before applying the two-item cap', () => {
74+
const usdcVault = buildVault({
75+
address: '0x0000000000000000000000000000000000000001',
76+
assetSymbol: 'USDC',
77+
tvl: 2_000_000,
78+
apr: 0.06
79+
})
80+
const daiVault = buildVault({
81+
address: '0x0000000000000000000000000000000000000002',
82+
assetSymbol: 'DAI',
83+
tvl: 1_500_000,
84+
apr: 0.05
85+
})
86+
const wethVault = buildVault({
87+
address: '0x0000000000000000000000000000000000000003',
88+
assetSymbol: 'WETH',
89+
tvl: 1_250_000,
90+
apr: 0.05
91+
})
92+
93+
const detectedTokens: TExternalToken[] = [
94+
{
95+
address: '0x0000000000000000000000000000000000000101',
96+
chainId: 1,
97+
protocol: 'Aave V3',
98+
underlyingSymbol: 'USDC',
99+
underlyingAddress: '0x0000000000000000000000000000000000000010'
100+
},
101+
{
102+
address: '0x0000000000000000000000000000000000000102',
103+
chainId: 1,
104+
protocol: 'Compound V3',
105+
underlyingSymbol: 'USDC',
106+
underlyingAddress: '0x0000000000000000000000000000000000000010'
107+
},
108+
{
109+
address: '0x0000000000000000000000000000000000000103',
110+
chainId: 1,
111+
protocol: 'Spark',
112+
underlyingSymbol: 'DAI',
113+
underlyingAddress: '0x0000000000000000000000000000000000000011'
114+
},
115+
{
116+
address: '0x0000000000000000000000000000000000000104',
117+
chainId: 1,
118+
protocol: 'Morpho',
119+
underlyingSymbol: 'WETH',
120+
underlyingAddress: '0x0000000000000000000000000000000000000012'
121+
}
122+
]
123+
124+
const suggestions = buildVaultSuggestions(
125+
detectedTokens,
126+
{
127+
[usdcVault.address]: usdcVault,
128+
[daiVault.address]: daiVault,
129+
[wethVault.address]: wethVault
130+
},
131+
new Set()
132+
)
133+
134+
expect(suggestions).toHaveLength(2)
135+
expect(suggestions[0]?.vault).toBe(usdcVault)
136+
expect(suggestions[1]?.vault).toBe(daiVault)
137+
})
138+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { TExternalToken } from '@pages/portfolio/constants/externalTokens'
2+
import { getEligibleVaults, normalizeSymbol, selectPreferredVault } from '@pages/portfolio/hooks/getEligibleVaults'
3+
import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors'
4+
import { getVaultKey } from '@shared/hooks/useVaultFilterUtils'
5+
6+
export type TVaultSuggestion = {
7+
vault: TKongVault
8+
externalProtocol: string
9+
underlyingSymbol: string
10+
}
11+
12+
export function buildVaultSuggestions(
13+
detectedTokens: TExternalToken[],
14+
vaults: Record<string, TKongVault>,
15+
holdingsKeySet: Set<string>
16+
): TVaultSuggestion[] {
17+
if (detectedTokens.length === 0) return []
18+
19+
const eligible = getEligibleVaults(vaults, holdingsKeySet)
20+
21+
const vaultsBySymbol = eligible.reduce((acc, vault) => {
22+
const normalized = normalizeSymbol(getVaultToken(vault).symbol ?? '')
23+
return acc.set(normalized, [...(acc.get(normalized) ?? []), vault])
24+
}, new Map<string, TKongVault[]>())
25+
26+
const bestVaultByUnderlying = new Map<string, TKongVault>(
27+
[...vaultsBySymbol.entries()]
28+
.map(([symbol, candidates]) => [symbol, selectPreferredVault(candidates)] as const)
29+
.filter((entry): entry is [string, TKongVault] => entry[1] !== undefined)
30+
)
31+
32+
const seenVaults = new Set<string>()
33+
34+
return detectedTokens
35+
.flatMap((token) => {
36+
const normalized = normalizeSymbol(token.underlyingSymbol)
37+
const bestVault = bestVaultByUnderlying.get(normalized)
38+
if (!bestVault) return []
39+
40+
return [{ vault: bestVault, externalProtocol: token.protocol, underlyingSymbol: token.underlyingSymbol }]
41+
})
42+
.filter((suggestion) => {
43+
const vaultKey = getVaultKey(suggestion.vault)
44+
if (seenVaults.has(vaultKey)) {
45+
return false
46+
}
47+
seenVaults.add(vaultKey)
48+
return true
49+
})
50+
.slice(0, 2)
51+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors'
2+
import { describe, expect, it } from 'vitest'
3+
import { selectPreferredVault } from './getEligibleVaults'
4+
5+
const buildVault = ({
6+
address,
7+
assetSymbol,
8+
tvl,
9+
apr,
10+
version = '3.0.0'
11+
}: {
12+
address: string
13+
assetSymbol: string
14+
tvl: number
15+
apr: number
16+
version?: string
17+
}): TKongVault =>
18+
({
19+
chainId: 1,
20+
address,
21+
name: `${assetSymbol} Vault`,
22+
symbol: `yv${assetSymbol}`,
23+
apiVersion: version,
24+
decimals: 18,
25+
asset: {
26+
address: '0x0000000000000000000000000000000000000010',
27+
name: assetSymbol,
28+
symbol: assetSymbol,
29+
decimals: 18
30+
},
31+
tvl,
32+
performance: {
33+
oracle: { apr, apy: apr },
34+
estimated: {
35+
apr,
36+
apy: apr,
37+
type: 'estimated',
38+
components: {}
39+
},
40+
historical: {
41+
net: apr,
42+
weeklyNet: apr,
43+
monthlyNet: apr,
44+
inceptionNet: apr
45+
}
46+
},
47+
fees: {
48+
managementFee: 0.0025,
49+
performanceFee: 0.1
50+
},
51+
category: 'Stablecoin',
52+
type: 'Standard',
53+
kind: 'Single Strategy',
54+
v3: true,
55+
yearn: true,
56+
isRetired: false,
57+
isHidden: false,
58+
isBoosted: false,
59+
isHighlighted: true,
60+
strategiesCount: 1,
61+
riskLevel: 1,
62+
staking: {
63+
address: null,
64+
available: false
65+
}
66+
}) as unknown as TKongVault
67+
68+
describe('selectPreferredVault', () => {
69+
it('prefers the highest-TVL qualifying vault', () => {
70+
const smaller = buildVault({
71+
address: '0x0000000000000000000000000000000000000001',
72+
assetSymbol: 'USDC',
73+
tvl: 900_000,
74+
apr: 0.06
75+
})
76+
const larger = buildVault({
77+
address: '0x0000000000000000000000000000000000000002',
78+
assetSymbol: 'USDC',
79+
tvl: 2_500_000,
80+
apr: 0.05
81+
})
82+
83+
expect(selectPreferredVault([smaller, larger])).toBe(larger)
84+
})
85+
})

src/components/pages/portfolio/hooks/getEligibleVaults.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export function selectPreferredVault(candidates: TKongVault[]): TKongVault | und
3232

3333
if (qualifying.length > 0) {
3434
return qualifying.reduce((best, vault) =>
35-
(getVaultTVL(vault).tvl ?? 0) < (getVaultTVL(best).tvl ?? 0) ? vault : best
35+
(getVaultTVL(vault).tvl ?? 0) > (getVaultTVL(best).tvl ?? 0) ? vault : best
3636
)
3737
}
3838

src/components/pages/portfolio/hooks/useVaultSuggestions.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
import { EXTERNAL_TOKENS } from '@pages/portfolio/constants/externalTokens'
2-
import { getEligibleVaults, normalizeSymbol, selectPreferredVault } from '@pages/portfolio/hooks/getEligibleVaults'
3-
import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors'
2+
import { buildVaultSuggestions, type TVaultSuggestion } from '@pages/portfolio/hooks/buildVaultSuggestions'
43
import { useWeb3 } from '@shared/contexts/useWeb3'
54
import { useYearn } from '@shared/contexts/useYearn'
65
import { useEnsoBalances } from '@shared/hooks/useEnsoBalances'
76
import { toAddress } from '@shared/utils'
87
import { useMemo } from 'react'
98

10-
export type TVaultSuggestion = {
11-
vault: TKongVault
12-
externalProtocol: string
13-
underlyingSymbol: string
14-
}
15-
169
export function useVaultSuggestions(holdingsKeySet: Set<string>): {
1710
suggestions: TVaultSuggestion[]
1811
} {
@@ -30,30 +23,7 @@ export function useVaultSuggestions(holdingsKeySet: Set<string>): {
3023
)
3124

3225
const suggestions = useMemo(() => {
33-
if (detectedTokens.length === 0) return []
34-
35-
const eligible = getEligibleVaults(vaults, holdingsKeySet)
36-
37-
const vaultsBySymbol = eligible.reduce((acc, vault) => {
38-
const normalized = normalizeSymbol(getVaultToken(vault).symbol ?? '')
39-
return acc.set(normalized, [...(acc.get(normalized) ?? []), vault])
40-
}, new Map<string, TKongVault[]>())
41-
42-
const bestVaultByUnderlying = new Map<string, TKongVault>(
43-
[...vaultsBySymbol.entries()]
44-
.map(([symbol, candidates]) => [symbol, selectPreferredVault(candidates)] as const)
45-
.filter((entry): entry is [string, TKongVault] => entry[1] !== undefined)
46-
)
47-
48-
return detectedTokens
49-
.flatMap((token) => {
50-
const normalized = normalizeSymbol(token.underlyingSymbol)
51-
const bestVault = bestVaultByUnderlying.get(normalized)
52-
if (!bestVault) return []
53-
54-
return [{ vault: bestVault, externalProtocol: token.protocol, underlyingSymbol: token.underlyingSymbol }]
55-
})
56-
.slice(0, 2)
26+
return buildVaultSuggestions(detectedTokens, vaults, holdingsKeySet)
5727
}, [detectedTokens, vaults, holdingsKeySet])
5828

5929
return { suggestions }

0 commit comments

Comments
 (0)