diff --git a/README.md b/README.md index e2c7d67ef..988561c0b 100755 --- a/README.md +++ b/README.md @@ -41,6 +41,32 @@ 2. Run `bun run dev` 3. Open the browser and navigate to `http://localhost:3000` +### Per-vault Enso denylist (disable zaps) + +To disable Enso routing for specific vaults, edit: + +`src/components/pages/vaults/constants/ensoDisabledVaults.ts` + +Add vault addresses under their chain ID: + +```ts +const ENSO_DISABLED_VAULTS_BY_CHAIN: Partial> = { + 1: [ + '0x1111111111111111111111111111111111111111' + ], + 42161: [ + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333' + ] +} +``` + +Notes: +- Keys are EVM chain IDs (`1`, `10`, `137`, `42161`, etc.). +- Values are vault addresses for that chain. +- Address casing does not matter (addresses are normalized internally). +- Denylisted vaults disable Enso for both deposit and withdraw flows and hide zap UI on vault pages. + ### Making Changes - Create a new local branch from upstream/main for each PR that you will submit diff --git a/api/server.ts b/api/server.ts index 0563b5039..edd9f459a 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,6 +1,47 @@ import { serve } from 'bun' const ENSO_API_BASE = 'https://api.enso.finance' +const YVUSD_APR_SERVICE_API = ( + process.env.YVUSD_APR_SERVICE_API || 'https://yearn-yvusd-apr-service.vercel.app/api/aprs' +).replace(/\/$/, '') + +async function handleYvUsdAprs(req: Request): Promise { + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405 }) + } + + const requestUrl = new URL(req.url) + const upstreamUrl = new URL(YVUSD_APR_SERVICE_API) + requestUrl.searchParams.forEach((value, key) => { + upstreamUrl.searchParams.set(key, value) + }) + + try { + const response = await fetch(upstreamUrl.toString(), { + headers: { + Accept: 'application/json' + } + }) + + if (!response.ok) { + const details = await response.text() + return Response.json( + { error: 'yvUSD APR upstream error', status: response.status, details }, + { status: response.status } + ) + } + + const data = await response.json() + return Response.json(data, { + headers: { + 'Cache-Control': 'public, s-maxage=30, stale-while-revalidate=120' + } + }) + } catch (error) { + console.error('Error proxying yvUSD APR request:', error) + return Response.json({ error: 'Internal server error' }, { status: 500 }) + } +} function handleEnsoStatus(): Response { const apiKey = process.env.ENSO_API_KEY @@ -133,6 +174,10 @@ serve({ return handleEnsoRoute(req) } + if (url.pathname === '/api/yvusd/aprs') { + return handleYvUsdAprs(req) + } + return new Response('Not found', { status: 404 }) }, port: 3001 diff --git a/api/yvusd/aprs.ts b/api/yvusd/aprs.ts new file mode 100644 index 000000000..f91bfa7dc --- /dev/null +++ b/api/yvusd/aprs.ts @@ -0,0 +1,42 @@ +import type { VercelRequest, VercelResponse } from '@vercel/node' + +const YVUSD_APR_SERVICE_API = ( + process.env.YVUSD_APR_SERVICE_API || 'https://yearn-yvusd-apr-service.vercel.app/api/aprs' +).replace(/\/$/, '') + +export default async function handler(req: VercelRequest, res: VercelResponse) { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const upstreamUrl = new URL(YVUSD_APR_SERVICE_API) + Object.entries(req.query).forEach(([key, value]) => { + if (typeof value === 'string') { + upstreamUrl.searchParams.set(key, value) + } + }) + + const response = await fetch(upstreamUrl.toString(), { + headers: { + Accept: 'application/json' + } + }) + + if (!response.ok) { + const details = await response.text() + return res.status(response.status).json({ + error: 'yvUSD APR upstream error', + status: response.status, + details + }) + } + + const data = await response.json() + res.setHeader('Cache-Control', 'public, s-maxage=30, stale-while-revalidate=120') + return res.status(200).json(data) + } catch (error) { + console.error('Error proxying yvUSD APR request:', error) + return res.status(500).json({ error: 'Internal server error' }) + } +} diff --git a/bun.lock b/bun.lock index 337967d2e..889b7c9ea 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "yearnfi", diff --git a/public/lock-closed-white.svg b/public/lock-closed-white.svg new file mode 100644 index 000000000..1e0159121 --- /dev/null +++ b/public/lock-closed-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/lock-closed.svg b/public/lock-closed.svg new file mode 100644 index 000000000..2fc9628c8 --- /dev/null +++ b/public/lock-closed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/lock-open-white.svg b/public/lock-open-white.svg new file mode 100644 index 000000000..a0bf19c82 --- /dev/null +++ b/public/lock-open-white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/lock-open-white2.svg b/public/lock-open-white2.svg new file mode 100644 index 000000000..d5910dec2 --- /dev/null +++ b/public/lock-open-white2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/lock-open.svg b/public/lock-open.svg new file mode 100644 index 000000000..36767d786 --- /dev/null +++ b/public/lock-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/yvUSD-C-seal.png b/public/yvUSD-C-seal.png new file mode 100644 index 000000000..8c7d3ee1d Binary files /dev/null and b/public/yvUSD-C-seal.png differ diff --git a/public/yvUSD-C.png b/public/yvUSD-C.png new file mode 100644 index 000000000..e2ca64d79 Binary files /dev/null and b/public/yvUSD-C.png differ diff --git a/public/yvUSD-seal.png b/public/yvUSD-seal.png new file mode 100644 index 000000000..a6018c18d Binary files /dev/null and b/public/yvUSD-seal.png differ diff --git a/public/yvUSD.png b/public/yvUSD.png new file mode 100644 index 000000000..a95148ccf Binary files /dev/null and b/public/yvUSD.png differ diff --git a/public/yvusd-banner-bg.png b/public/yvusd-banner-bg.png new file mode 100644 index 000000000..2790e5251 Binary files /dev/null and b/public/yvusd-banner-bg.png differ diff --git a/src/components/pages/portfolio/constants/externalTokens.ts b/src/components/pages/portfolio/constants/externalTokens.ts new file mode 100644 index 000000000..efc3f4547 --- /dev/null +++ b/src/components/pages/portfolio/constants/externalTokens.ts @@ -0,0 +1,172 @@ +export type TExternalToken = { + address: string + chainId: number + protocol: string + underlyingSymbol: string + underlyingAddress: string +} + +export const EXTERNAL_TOKENS: TExternalToken[] = [ + // Aave V3 (Ethereum) + { + address: '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c', + chainId: 1, + protocol: 'Aave V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + }, + { + address: '0x4d5F47FA6A74757f35C14fD3a6Ef8E3C9BC514E8', + chainId: 1, + protocol: 'Aave V3', + underlyingSymbol: 'WETH', + underlyingAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + }, + { + address: '0x018008bfb33d285247A21d44E50697654f754e63', + chainId: 1, + protocol: 'Aave V3', + underlyingSymbol: 'DAI', + underlyingAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + }, + { + address: '0x23878914EFE38d27C4D67Ab83ed1b93A74D4086a', + chainId: 1, + protocol: 'Aave V3', + underlyingSymbol: 'USDT', + underlyingAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + }, + { + address: '0x5Ee5bf7ae06D1Be5997A1A72006FE6C607eC6DE8', + chainId: 1, + protocol: 'Aave V3', + underlyingSymbol: 'WBTC', + underlyingAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' + }, + // Compound V3 (Ethereum) + { + address: '0xc3d688B66703497DAA19211EEdff47f25384cdc3', + chainId: 1, + protocol: 'Compound V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + }, + { + address: '0xA17581A9E3356d9A858b789D68B4d866e593aE94', + chainId: 1, + protocol: 'Compound V3', + underlyingSymbol: 'WETH', + underlyingAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + }, + // Spark (Ethereum) + { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + protocol: 'Spark', + underlyingSymbol: 'DAI', + underlyingAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + }, + { + address: '0xC7B5EB38B554dEc4F1fB31bfbe08D81A4Ff09EaE', + chainId: 1, + protocol: 'Spark', + underlyingSymbol: 'DAI', + underlyingAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + }, + // Morpho (Ethereum) - key vault receipt tokens + { + address: '0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'USDC', + underlyingAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + }, + { + address: '0x78Fc2c2eD71dAb0491d268d1a40B6d6f44b2BeC8', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'WETH', + underlyingAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + }, + { + address: '0x2371e134e3455e0593363cBF89d3b6cf53740618', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'DAI', + underlyingAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F' + }, + { + address: '0x8CB3649114051E4C8F3816Ef3f980cD1635Aba27', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'USDT', + underlyingAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7' + }, + { + address: '0xd63070114470f685b75B74D60EEc7c1113d33a3D', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'WETH', + underlyingAddress: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + }, + { + address: '0x4881Ef0BF6d2365D3dd6499ccd7532bcdBcE0658', + chainId: 1, + protocol: 'Morpho', + underlyingSymbol: 'USDC', + underlyingAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' + }, + // Aave V3 (Arbitrum) + { + address: '0x724dc807b04555b71ed48a6896b6F41593b8C637', + chainId: 42161, + protocol: 'Aave V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' + }, + { + address: '0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8', + chainId: 42161, + protocol: 'Aave V3', + underlyingSymbol: 'WETH', + underlyingAddress: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1' + }, + // Aave V3 (Base) + { + address: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', + chainId: 8453, + protocol: 'Aave V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' + }, + { + address: '0xD4a0e0b9149BCee3C920d2E00b5dE09138fd8bb7', + chainId: 8453, + protocol: 'Aave V3', + underlyingSymbol: 'WETH', + underlyingAddress: '0x4200000000000000000000000000000000000006' + }, + // Aave V3 (Optimism) + { + address: '0x38d693cE1dF5AaDF7bC62043e37bC30b0B186AF', + chainId: 10, + protocol: 'Aave V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85' + }, + // Aave V3 (Polygon) + { + address: '0xA4D94019934D8333Ef880ABFFbF2FDd611C0b352', + chainId: 137, + protocol: 'Aave V3', + underlyingSymbol: 'USDC', + underlyingAddress: '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359' + }, + { + address: '0xe50fA9b3c56FfB159cB0FCA61F5c9D750e8128c8', + chainId: 137, + protocol: 'Aave V3', + underlyingSymbol: 'WETH', + underlyingAddress: '0x7ceB23fD6bC0adD59E62ac25578270cFf1b9f619' + } +] 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 new file mode 100644 index 000000000..ef667f6f5 --- /dev/null +++ b/src/components/pages/portfolio/hooks/getEligibleVaults.ts @@ -0,0 +1,55 @@ +import { + getVaultAPR, + getVaultInfo, + getVaultMigration, + getVaultToken, + getVaultTVL, + getVaultVersion, + type TKongVault +} from '@pages/vaults/domain/kongVaultSelectors' +import { deriveListKind, UNDERLYING_ASSET_OVERRIDES } from '@pages/vaults/utils/vaultListFacets' +import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' + +export function normalizeSymbol(symbol: string): string { + const upper = symbol.trim().toUpperCase() + return UNDERLYING_ASSET_OVERRIDES[upper] ?? upper +} + +const isV3Vault = (vault: TKongVault): boolean => { + const version = getVaultVersion(vault) + return version.startsWith('3') || version.startsWith('~3') +} + +export function selectPreferredVault(candidates: TKongVault[]): TKongVault | undefined { + const v3Candidates = candidates.filter(isV3Vault) + if (v3Candidates.length === 0) return undefined + + const qualifying = v3Candidates.filter((vault) => { + const tvl = getVaultTVL(vault).tvl ?? 0 + const apr = getVaultAPR(vault).forwardAPR.netAPR + return tvl > 500_000 && apr > 0.04 + }) + + if (qualifying.length > 0) { + return qualifying.reduce((best, vault) => + (getVaultTVL(vault).tvl ?? 0) > (getVaultTVL(best).tvl ?? 0) ? vault : best + ) + } + + return v3Candidates.reduce((best, vault) => + (getVaultTVL(vault).tvl ?? 0) > (getVaultTVL(best).tvl ?? 0) ? vault : best + ) +} + +export function getEligibleVaults(vaults: Record, holdingsKeySet: Set): TKongVault[] { + return Object.values(vaults).filter((vault) => { + const info = getVaultInfo(vault) + const migration = getVaultMigration(vault) + if (Boolean(info.isHidden) || Boolean(info.isRetired) || Boolean(migration.available)) return false + if (deriveListKind(vault) !== 'allocator') return false + if (getVaultAPR(vault).forwardAPR.netAPR <= 0.005) return false + if ((getVaultTVL(vault).tvl ?? 0) <= 0) return false + if (holdingsKeySet.has(getVaultKey(vault))) return false + return (getVaultToken(vault).symbol ?? '').trim() !== '' + }) +} 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..944d683c5 --- /dev/null +++ b/src/components/pages/portfolio/hooks/portfolioVisibility.ts @@ -0,0 +1,9 @@ +import { getVaultInfo, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' + +export function filterVisiblePortfolioHoldings(vaults: T[], showHiddenVaults: boolean): T[] { + 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 d3e185148..9f481011d 100644 --- a/src/components/pages/portfolio/hooks/usePortfolioModel.ts +++ b/src/components/pages/portfolio/hooks/usePortfolioModel.ts @@ -1,3 +1,5 @@ +import { useTokenSuggestions } from '@pages/portfolio/hooks/useTokenSuggestions' +import { useVaultSuggestions } from '@pages/portfolio/hooks/useVaultSuggestions' import { KATANA_CHAIN_ID } from '@pages/vaults/constants/addresses' import { getVaultAddress, @@ -5,29 +7,44 @@ import { getVaultInfo, getVaultMigration, getVaultStaking, - type TKongVault + type TKongVault, + type TKongVaultInput } 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 { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' +import { usePersistedShowHiddenVaults } from '@pages/vaults/hooks/vaultsFiltersStorage' import { deriveListKind, isAllocatorVaultOverride } from '@pages/vaults/utils/vaultListFacets' +import { + getWeightedYvUsdApy, + getYvUsdSharePrice, + isYvUsdAddress, + isYvUsdVault, + YVUSD_CHAIN_ID, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' 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 { useCallback, useMemo, useState } from 'react' +import { filterVisiblePortfolioHoldings } from './portfolioVisibility' type THoldingsRow = { key: string - vault: TKongVault + vault: TKongVaultInput hrefOverride?: string } -type TSuggestedVaultRow = { - key: string - vault: TKongVault -} +export type TSuggestedItem = + | { type: 'external'; key: string; vault: TKongVault; externalProtocol: string; underlyingSymbol: string } + | { type: 'personalized'; key: string; vault: TKongVault; matchedSymbol: string } + | { type: 'generic'; key: string; vault: TKongVault } export type TPortfolioBlendedMetrics = { blendedCurrentAPY: number | null @@ -46,7 +63,7 @@ export type TPortfolioModel = { openLoginModal: () => void sortBy: TPossibleSortBy sortDirection: TSortDirection - suggestedRows: TSuggestedVaultRow[] + suggestedRows: TSuggestedItem[] totalPortfolioValue: number vaultFlags: Record setSortBy: TSortStateSetter @@ -54,47 +71,101 @@ export type TPortfolioModel = { } type TSortStateSetter = (value: T | ((previous: T) => T)) => void +type TYvUsdPortfolioPosition = { + blendedCurrentApy: number | null + blendedHistoricalApy: number | null + combinedValue: number + hasHoldings: boolean +} function getChainAddressKey(chainID: number | undefined, address: string): string { return `${chainID}_${toAddress(address)}` } -function isPortfolioV3Vault(vault: TKongVault): boolean { +function isPortfolioV3Vault(vault: TKongVaultInput): boolean { return isV3Vault(vault, isAllocatorVaultOverride(vault)) } +function getPortfolioRowHref(vault: TKongVaultInput): string | undefined { + if (isPortfolioV3Vault(vault)) { + return undefined + } + return `/vaults/${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}` +} + export function usePortfolioModel(): TPortfolioModel { const { 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 { listVault: yvUsdVault, unlockedVault: yvUsdUnlockedVault, lockedVault: yvUsdLockedVault } = useYvUsdVaults() + const showHiddenVaults = usePersistedShowHiddenVaults() const [sortBy, setSortBy] = useState('deposited') const [sortDirection, setSortDirection] = useState('desc') + const yvUsdPosition = useMemo(() => { + const unlockedBalance = getBalance({ address: YVUSD_UNLOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }) + const lockedBalance = getBalance({ address: YVUSD_LOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }) + const unlockedValue = unlockedBalance.normalized * getYvUsdSharePrice(yvUsdUnlockedVault) + const lockedValue = lockedBalance.normalized * getYvUsdSharePrice(yvUsdLockedVault) + + return { + blendedCurrentApy: getWeightedYvUsdApy({ + unlockedValue, + lockedValue, + unlockedApy: yvUsdUnlockedVault ? calculateVaultEstimatedAPY(yvUsdUnlockedVault) || null : null, + lockedApy: yvUsdLockedVault ? calculateVaultEstimatedAPY(yvUsdLockedVault) || null : null + }), + blendedHistoricalApy: getWeightedYvUsdApy({ + unlockedValue, + lockedValue, + unlockedApy: yvUsdUnlockedVault ? calculateVaultHistoricalAPY(yvUsdUnlockedVault) : null, + lockedApy: yvUsdLockedVault ? calculateVaultHistoricalAPY(yvUsdLockedVault) : null + }), + combinedValue: unlockedValue + lockedValue, + hasHoldings: unlockedBalance.raw > 0n || lockedBalance.raw > 0n + } + }, [getBalance, yvUsdLockedVault, yvUsdUnlockedVault]) + const vaultLookup = useMemo(() => { - const map = new Map() + const map = new Map() - Object.values(vaults).forEach((vault) => { - const vaultKey = getVaultKey(vault) - map.set(vaultKey, vault) + Object.values(allVaults).forEach((vault) => { + if (isYvUsdAddress(getVaultAddress(vault))) { + return + } + 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 (staking?.available && staking.address) { - const stakingKey = getChainAddressKey(getVaultChainID(vault), staking.address) - map.set(stakingKey, 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 - }, [vaults]) + }, [allVaults]) const holdingsVaults = useMemo(() => { - const result: TKongVault[] = [] + const result: TKongVaultInput[] = [] const seen = new Set() Object.entries(balances || {}).forEach(([chainIDKey, perChain]) => { @@ -104,6 +175,9 @@ export function usePortfolioModel(): TPortfolioModel { if (!token?.balance || token.balance.raw <= 0n) { return } + if (isYvUsdAddress(token.address)) { + return + } const tokenChainID = chainID ?? token.chainID const tokenKey = getChainAddressKey(tokenChainID, token.address) const vault = vaultLookup.get(tokenKey) @@ -119,31 +193,42 @@ export function usePortfolioModel(): TPortfolioModel { }) }) + if (yvUsdVault && yvUsdPosition.hasHoldings) { + const yvUsdKey = getVaultKey(yvUsdVault) + if (!seen.has(yvUsdKey)) { + seen.add(yvUsdKey) + result.push(yvUsdVault) + } + } + return result - }, [balances, vaultLookup]) + }, [balances, vaultLookup, yvUsdPosition.hasHoldings, yvUsdVault]) + + const visibleHoldingsVaults = useMemo( + () => filterVisiblePortfolioHoldings(holdingsVaults, showHiddenVaults), + [holdingsVaults, showHiddenVaults] + ) const vaultFlags = useMemo(() => { const flags: Record = {} - holdingsVaults.forEach((vault) => { + visibleHoldingsVaults.forEach((vault) => { const key = getVaultKey(vault) - const info = getVaultInfo(vault) - const migration = getVaultMigration(vault) flags[key] = { hasHoldings: true, - isMigratable: Boolean(migration?.available), - isRetired: Boolean(info?.isRetired), - isHidden: Boolean(info?.isHidden) + isMigratable: Boolean(getVaultMigration(vault)?.available), + isRetired: Boolean(getVaultInfo(vault)?.isRetired), + isHidden: Boolean(getVaultInfo(vault)?.isHidden), + isNotYearn: isYvUsdVault(vault) ? false : isNonYearnErc4626Vault({ vault: vault as TKongVault }) } }) return flags - }, [holdingsVaults]) + }, [visibleHoldingsVaults]) const isSearchingBalances = (isActive || isUserConnecting) && (isWalletLoading || isUserConnecting || isIdentityLoading) - const isLoading = isLoadingVaultList - const isHoldingsLoading = (isLoading && isActive) || isSearchingBalances + const isHoldingsLoading = (isLoadingVaultList && isActive) || isSearchingBalances const suggestedVaultCandidates = useMemo( () => @@ -152,43 +237,77 @@ 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]) - const suggestedVaults = useMemo( + const genericVaults = useMemo( () => sortedCandidates.filter((vault) => !holdingsKeySet.has(getVaultKey(vault))).slice(0, 4), [sortedCandidates, holdingsKeySet] ) - const holdingsRows = useMemo(() => { - return sortedHoldings.map((vault) => { - const key = getVaultKey(vault) - const hrefOverride = isPortfolioV3Vault(vault) - ? undefined - : `/vaults/${getVaultChainID(vault)}/${toAddress(getVaultAddress(vault))}` - return { key, vault, hrefOverride } - }) - }, [sortedHoldings]) + const tokenSuggestions = useTokenSuggestions(holdingsKeySet) + const { suggestions: vaultSuggestions } = useVaultSuggestions(holdingsKeySet) - const suggestedRows = useMemo( - () => suggestedVaults.map((vault) => ({ key: getVaultKey(vault), vault })), - [suggestedVaults] + const holdingsRows = useMemo( + () => + sortedHoldings.map((vault) => ({ + key: getVaultKey(vault), + vault, + hrefOverride: getPortfolioRowHref(vault) + })), + [sortedHoldings] ) + const suggestedRows = useMemo((): TSuggestedItem[] => { + const candidates: { item: TSuggestedItem; vaultKey: string }[] = [ + ...vaultSuggestions.slice(0, 2).map((ext) => ({ + item: { + type: 'external' as const, + key: `ext-${getVaultKey(ext.vault)}`, + vault: ext.vault, + externalProtocol: ext.externalProtocol, + underlyingSymbol: ext.underlyingSymbol + }, + vaultKey: getVaultKey(ext.vault) + })), + ...tokenSuggestions.map((ps) => ({ + item: { + type: 'personalized' as const, + key: `pers-${getVaultKey(ps.vault)}`, + vault: ps.vault, + matchedSymbol: ps.matchedSymbol + }, + vaultKey: getVaultKey(ps.vault) + })), + ...genericVaults.map((vault) => ({ + item: { type: 'generic' as const, key: `gen-${getVaultKey(vault)}`, vault }, + vaultKey: getVaultKey(vault) + })) + ] + + const seen = new Set() + return candidates + .filter(({ vaultKey }) => { + if (seen.has(vaultKey)) return false + seen.add(vaultKey) + return true + }) + .slice(0, 4) + .map(({ item }) => item) + }, [vaultSuggestions, tokenSuggestions, genericVaults]) + const hasHoldings = sortedHoldings.length > 0 const hasKatanaHoldings = useMemo( () => holdingsVaults.some((vault) => getVaultChainID(vault) === KATANA_CHAIN_ID), @@ -196,98 +315,69 @@ export function usePortfolioModel(): TPortfolioModel { ) const totalPortfolioValue = (cumulatedValueInV2Vaults || 0) + (cumulatedValueInV3Vaults || 0) - const getVaultEstimatedAPY = useMemo( - () => - (vault: (typeof holdingsVaults)[number]): number | null => { - const apy = calculateVaultEstimatedAPY(vault) - return apy === 0 ? null : apy - }, - [] + const getVaultEstimatedAPY = useCallback( + (vault: (typeof holdingsVaults)[number]): number | null => { + if (isYvUsdVault(vault)) { + return yvUsdPosition.blendedCurrentApy + } + + const apy = calculateVaultEstimatedAPY(vault) + const hasHistoricalNet = 'performance' in vault && Boolean(vault.performance?.historical?.net) + return apy === 0 && !hasHistoricalNet ? null : apy + }, + [yvUsdPosition.blendedCurrentApy] ) - const getVaultHistoricalAPY = useMemo( - () => - (vault: (typeof holdingsVaults)[number]): number | null => { - return calculateVaultHistoricalAPY(vault) - }, - [] + const getVaultHistoricalAPY = useCallback( + (vault: (typeof holdingsVaults)[number]): number | null => { + if (isYvUsdVault(vault)) { + return yvUsdPosition.blendedHistoricalApy + } + + return calculateVaultHistoricalAPY(vault) + }, + [yvUsdPosition.blendedHistoricalApy] ) - 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 - }, - [getBalance, getPrice] + const getVaultValue = useCallback( + (vault: (typeof holdingsVaults)[number]): number => { + if (isYvUsdVault(vault)) { + return yvUsdPosition.combinedValue + } + + return getVaultHoldingsUsd(vault) + }, + [getVaultHoldingsUsd, yvUsdPosition.combinedValue] ) const blendedMetrics = useMemo(() => { + const isFiniteNumber = (v: number | null): v is number => v !== null && Number.isFinite(v) + const { totalValue, weightedCurrent, weightedHistorical, hasCurrent, hasHistorical } = holdingsVaults.reduce( (acc, vault) => { const value = getVaultValue(vault) - if (!Number.isFinite(value) || value <= 0) { - return acc - } + if (!Number.isFinite(value) || value <= 0) return acc const estimatedAPY = getVaultEstimatedAPY(vault) - const newWeightedCurrent = - typeof estimatedAPY === 'number' && Number.isFinite(estimatedAPY) - ? acc.weightedCurrent + value * estimatedAPY - : acc.weightedCurrent - const newHasCurrent = acc.hasCurrent || (typeof estimatedAPY === 'number' && Number.isFinite(estimatedAPY)) - const historicalAPY = getVaultHistoricalAPY(vault) - const newWeightedHistorical = - typeof historicalAPY === 'number' && Number.isFinite(historicalAPY) - ? acc.weightedHistorical + value * historicalAPY - : acc.weightedHistorical - const newHasHistorical = - acc.hasHistorical || (typeof historicalAPY === 'number' && Number.isFinite(historicalAPY)) return { totalValue: acc.totalValue + value, - weightedCurrent: newWeightedCurrent, - weightedHistorical: newWeightedHistorical, - hasCurrent: newHasCurrent, - hasHistorical: newHasHistorical + weightedCurrent: acc.weightedCurrent + (isFiniteNumber(estimatedAPY) ? value * estimatedAPY : 0), + weightedHistorical: acc.weightedHistorical + (isFiniteNumber(historicalAPY) ? value * historicalAPY : 0), + hasCurrent: acc.hasCurrent || isFiniteNumber(estimatedAPY), + hasHistorical: acc.hasHistorical || isFiniteNumber(historicalAPY) } }, { totalValue: 0, weightedCurrent: 0, weightedHistorical: 0, hasCurrent: false, hasHistorical: false } ) - const blendedCurrentAPY = totalValue > 0 && hasCurrent ? weightedCurrent / totalValue : null - const blendedHistoricalAPY = totalValue > 0 && hasHistorical ? weightedHistorical / totalValue : null - const blendedCurrentAPYPercent = blendedCurrentAPY !== null ? blendedCurrentAPY * 100 : null - const blendedHistoricalAPYPercent = blendedHistoricalAPY !== null ? blendedHistoricalAPY * 100 : null - const estimatedAnnualReturn = blendedCurrentAPY !== null ? totalPortfolioValue * blendedCurrentAPY : null + const blendedCurrentAPY = totalValue > 0 && hasCurrent ? (weightedCurrent / totalValue) * 100 : null + const blendedHistoricalAPY = totalValue > 0 && hasHistorical ? (weightedHistorical / totalValue) * 100 : null + const estimatedAnnualReturn = + totalValue > 0 && hasCurrent ? totalPortfolioValue * (weightedCurrent / totalValue) : null - return { - blendedCurrentAPY: blendedCurrentAPYPercent, - blendedHistoricalAPY: blendedHistoricalAPYPercent, - estimatedAnnualReturn - } + return { blendedCurrentAPY, blendedHistoricalAPY, estimatedAnnualReturn } }, [getVaultEstimatedAPY, getVaultHistoricalAPY, getVaultValue, holdingsVaults, totalPortfolioValue]) return { diff --git a/src/components/pages/portfolio/hooks/useTokenSuggestions.ts b/src/components/pages/portfolio/hooks/useTokenSuggestions.ts new file mode 100644 index 000000000..e9e57855c --- /dev/null +++ b/src/components/pages/portfolio/hooks/useTokenSuggestions.ts @@ -0,0 +1,59 @@ +import { getEligibleVaults, normalizeSymbol, selectPreferredVault } from '@pages/portfolio/hooks/getEligibleVaults' +import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { useWallet } from '@shared/contexts/useWallet' +import { useYearn } from '@shared/contexts/useYearn' +import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' +import { useMemo } from 'react' + +export type TTokenSuggestion = { + vault: TKongVault + matchedSymbol: string +} + +export function useTokenSuggestions(holdingsKeySet: Set): TTokenSuggestion[] { + const { balances } = useWallet() + const { vaults } = useYearn() + + return useMemo(() => { + const userTokens = Object.values(balances ?? {}).flatMap((perChain) => + Object.values(perChain ?? {}).filter( + (token) => token?.balance && token.balance.raw > 0n && token.symbol && token.value > 1 + ) + ) + + const symbolTotals = userTokens.reduce((acc, { symbol, value }) => { + const normalized = normalizeSymbol(symbol) + if (!normalized) return acc + return acc.set(normalized, (acc.get(normalized) ?? 0) + value) + }, new Map()) + + const sortedSymbols = [...symbolTotals.entries()].sort((a, b) => b[1] - a[1]) + + const eligible = getEligibleVaults(vaults, holdingsKeySet) + + const vaultsBySymbol = eligible.reduce((acc, vault) => { + const vaultSymbol = normalizeSymbol(getVaultToken(vault).symbol ?? '') + if (!vaultSymbol) return acc + return acc.set(vaultSymbol, [...(acc.get(vaultSymbol) ?? []), vault]) + }, new Map()) + + return sortedSymbols.reduce<{ results: TTokenSuggestion[]; usedVaults: Set }>( + (acc, [symbol]) => { + if (acc.results.length >= 4) return acc + const candidates = vaultsBySymbol.get(symbol) + if (!candidates?.length) return acc + + const bestVault = selectPreferredVault(candidates.filter((vault) => !acc.usedVaults.has(getVaultKey(vault)))) + + if (!bestVault) return acc + + acc.usedVaults.add(getVaultKey(bestVault)) + return { + results: [...acc.results, { vault: bestVault, matchedSymbol: symbol }], + usedVaults: acc.usedVaults + } + }, + { results: [], usedVaults: new Set() } + ).results + }, [balances, vaults, holdingsKeySet]) +} diff --git a/src/components/pages/portfolio/hooks/useVaultSuggestions.ts b/src/components/pages/portfolio/hooks/useVaultSuggestions.ts new file mode 100644 index 000000000..21b57ae29 --- /dev/null +++ b/src/components/pages/portfolio/hooks/useVaultSuggestions.ts @@ -0,0 +1,30 @@ +import { EXTERNAL_TOKENS } from '@pages/portfolio/constants/externalTokens' +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 function useVaultSuggestions(holdingsKeySet: Set): { + suggestions: TVaultSuggestion[] +} { + const { address } = useWeb3() + const { vaults } = useYearn() + const { data: ensoBalances } = useEnsoBalances(address) + + const detectedTokens = useMemo( + () => + EXTERNAL_TOKENS.filter((token) => { + const balance = ensoBalances?.[token.chainId]?.[toAddress(token.address)]?.balance + return balance && balance.raw > 0n + }), + [ensoBalances] + ) + + const suggestions = useMemo(() => { + return buildVaultSuggestions(detectedTokens, vaults, holdingsKeySet) + }, [detectedTokens, vaults, holdingsKeySet]) + + return { suggestions } +} 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 958eb92e9..1c74f4f3f 100644 --- a/src/components/pages/portfolio/index.tsx +++ b/src/components/pages/portfolio/index.tsx @@ -1,5 +1,7 @@ import { usePlausible } from '@hooks/usePlausible' import { EmptySectionCard } from '@pages/portfolio/components/EmptySectionCard' +import { type TPortfolioModel, usePortfolioModel } from '@pages/portfolio/hooks/usePortfolioModel' +import { useVaultWithStakingRewards } from '@pages/portfolio/hooks/useVaultWithStakingRewards' import { VaultsListHead } from '@pages/vaults/components/list/VaultsListHead' import { VaultsListRow } from '@pages/vaults/components/list/VaultsListRow' import { Notification } from '@pages/vaults/components/notifications/Notification' @@ -27,15 +29,13 @@ 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' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router' import { useChainId, useSwitchChain } from 'wagmi' -import { type TPortfolioModel, usePortfolioModel } from './hooks/usePortfolioModel' -import { useVaultWithStakingRewards } from './hooks/useVaultWithStakingRewards' const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', @@ -108,7 +108,7 @@ function PortfolioHeaderSection({ ) const metricSpinner = ( - + ) @@ -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) @@ -915,22 +915,38 @@ function PortfolioSuggestedSection({ suggestedRows }: TPortfolioSuggestedProps): return null } + const hasPersonalized = suggestedRows.some((r) => r.type === 'personalized' || r.type === 'external') + const tooltipText = hasPersonalized + ? 'Suggestions based on tokens in your wallet and vault performance.' + : 'Vaults picked for you based on performance and popularity.' + return (
{'Vaults picked for you based on performance and popularity.'} - } + tooltip={
{tooltipText}
} >

{'You might like'}

- {suggestedRows.map((row) => ( - - ))} + {suggestedRows.map((row) => { + if (row.type === 'external') { + return ( + + ) + } + if (row.type === 'personalized') { + return + } + return + })}
) diff --git a/src/components/pages/vaults/[chainID]/[address].tsx b/src/components/pages/vaults/[chainID]/[address].tsx index c154d6b9e..f0dd73702 100644 --- a/src/components/pages/vaults/[chainID]/[address].tsx +++ b/src/components/pages/vaults/[chainID]/[address].tsx @@ -1,44 +1,68 @@ import Link from '@components/Link' import { useScrollSpy } from '@hooks/useScrollSpy' import { BottomDrawer } from '@pages/vaults/components/detail/BottomDrawer' -import { MobileKeyMetrics } from '@pages/vaults/components/detail/QuickStatsGrid' +import { + DESKTOP_WIDGET_BOTTOM_PADDING_PX, + DESKTOP_WIDGET_OFFSET_CSS_VAR, + getDesktopWidgetHeightClassNames, + resolveDesktopWidgetHeaderOffset +} from '@pages/vaults/components/detail/desktopWidgetSizing' +import { MobileKeyMetrics, YvUsdApyStatBox } from '@pages/vaults/components/detail/QuickStatsGrid' import { VaultAboutSection } from '@pages/vaults/components/detail/VaultAboutSection' import { VaultChartsSection } from '@pages/vaults/components/detail/VaultChartsSection' -import { VaultDetailsHeader } from '@pages/vaults/components/detail/VaultDetailsHeader' +import { VaultDetailsHeader, VaultDetailsHeaderPresentation } from '@pages/vaults/components/detail/VaultDetailsHeader' import { VaultInfoSection } from '@pages/vaults/components/detail/VaultInfoSection' import { VaultRiskSection } from '@pages/vaults/components/detail/VaultRiskSection' import { VaultStrategiesSection } from '@pages/vaults/components/detail/VaultStrategiesSection' +import { YvUsdChartsSection } from '@pages/vaults/components/detail/YvUsdChartsSection' import { VaultDetailsWelcomeTour } from '@pages/vaults/components/tour/VaultDetailsWelcomeTour' import type { TWidgetRef } from '@pages/vaults/components/widget' 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 { YvUsdWidget } from '@pages/vaults/components/widget/yvUSD/YvUsdWidget' +import { YvUsdHeaderBanner } from '@pages/vaults/components/yvUSD/YvUsdHeaderBanner' +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 { useVaultSnapshot } from '@pages/vaults/hooks/useVaultSnapshot' import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { WidgetActionType } from '@pages/vaults/types' +import { + getYvUsdSharePrice, + isYvUsdAddress, + type TYvUsdVariant, + YVUSD_CHAIN_ID, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' import { Breadcrumbs } from '@shared/components/Breadcrumbs' -import { ImageWithFallback } from '@shared/components/ImageWithFallback' +import { TokenLogo } from '@shared/components/TokenLogo' import { useWallet } from '@shared/contexts/useWallet' import { useYearn } from '@shared/contexts/useYearn' import { IconChevron } from '@shared/icons/IconChevron' -import type { TToken } from '@shared/types' -import { cl, isZeroAddress, toAddress } from '@shared/utils' +import { cl, isZeroAddress, toAddress, toNormalizedBN } from '@shared/utils' import { getVaultName } from '@shared/utils/helpers' import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' import type { ReactElement } from 'react' import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useParams } from 'react-router' +import { useLocation, useNavigate, useParams } from 'react-router' import { isAddressEqual } from 'viem' import { VaultsListChip } from '@/components/pages/vaults/components/list/VaultsListChip' import { deriveListKind } from '@/components/pages/vaults/utils/vaultListFacets' +import { getVaultPrimaryLogoSrc } from '@/components/pages/vaults/utils/vaultLogo' import { getCategoryDescription, getProductTypeDescription } from '@/components/pages/vaults/utils/vaultTagCopy' import { useWeb3 } from '@/components/shared/contexts/useWeb3' import { useDevFlags } from '@/contexts/useDevFlags' @@ -62,6 +86,8 @@ const resolveHeaderOffset = (): number => { return Number.isNaN(nextOffset) ? 0 : nextOffset } +const desktopWidgetHeightClassNames = getDesktopWidgetHeightClassNames() + const RETIRED_VAULT_ALERT_MESSAGES = { noFunds: 'This vault is retired.', sternMigration: 'This vault is retired. Please withdraw or migrate your funds.', @@ -174,13 +200,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 ( @@ -213,16 +245,91 @@ function RetiredVaultAlert({ message, className }: { message: string; className: ) } +function YvUsdMobileKeyMetrics({ + currentVault, + apyVariant, + onApyVariantChange +}: { + currentVault: TKongVaultView + apyVariant: TYvUsdVariant + onApyVariantChange: (variant: TYvUsdVariant) => void +}): ReactElement { + const { address } = useWeb3() + const { getPrice } = useYearn() + const { metrics, unlockedVault, lockedVault } = useYvUsdVaults() + const account = address ? toAddress(address) : undefined + const unlockedAssetAddress = toAddress(unlockedVault?.token.address ?? YVUSD_UNLOCKED_ADDRESS) + + const unlockedUserData = useVaultUserData({ + vaultAddress: toAddress(unlockedVault?.address ?? YVUSD_UNLOCKED_ADDRESS), + assetAddress: unlockedAssetAddress, + chainId: YVUSD_CHAIN_ID, + account + }) + const lockedUserData = useVaultUserData({ + vaultAddress: toAddress(lockedVault?.address ?? YVUSD_LOCKED_ADDRESS), + assetAddress: YVUSD_UNLOCKED_ADDRESS, + chainId: YVUSD_CHAIN_ID, + account + }) + + const unlockedNormalized = toNormalizedBN( + unlockedUserData.depositedValue, + unlockedUserData.assetToken?.decimals ?? 6 + ).normalized + const lockedNormalized = toNormalizedBN( + lockedUserData.depositedValue, + lockedUserData.assetToken?.decimals ?? 18 + ).normalized + const unlockedAssetPrice = + getPrice({ address: unlockedAssetAddress, chainID: YVUSD_CHAIN_ID }).normalized || unlockedVault?.tvl.price || 0 + const unlockedSharePrice = getYvUsdSharePrice(unlockedVault, unlockedAssetPrice) + const depositedUsdValue = unlockedNormalized * unlockedAssetPrice + lockedNormalized * unlockedSharePrice + const unlockedApy = metrics?.unlocked.apy ?? currentVault.apr.forwardAPR.netAPR ?? currentVault.apr.netAPR ?? 0 + const lockedApy = metrics?.locked.apy ?? lockedVault?.apr.forwardAPR.netAPR ?? lockedVault?.apr.netAPR ?? 0 + + return ( + + } + /> + ) +} + function Index(): ReactElement | null { type SectionKey = 'charts' | 'about' | 'risk' | 'strategies' | 'info' const { headerDisplayMode } = useDevFlags() const mobileDetailsSectionId = useId() const params = useParams() + const location = useLocation() + const navigate = useNavigate() const chainId = Number(params.chainID) const { getBalance, onRefresh } = useWallet() const { address } = useWeb3() - const { vaults, isLoadingVaultList, enableVaultListFetch } = useYearn() + const { vaults, allVaults, isLoadingVaultList, enableVaultListFetch } = useYearn() + const { + listVault: yvUsdVault, + unlockedVault: yvUsdUnlockedVault, + lockedVault: yvUsdLockedVault, + isLoading: isLoadingYvUsd + } = useYvUsdVaults() + const isYvUsd = isYvUsdAddress(params.address) + const isLockedYvUsdRoute = + chainId === YVUSD_CHAIN_ID && params.address ? toAddress(params.address) === YVUSD_LOCKED_ADDRESS : false + const unlockedYvUsdPath = `/vaults/${YVUSD_CHAIN_ID}/${YVUSD_UNLOCKED_ADDRESS}${location.search}${location.hash}` const vaultKey = `${params.chainID}-${params.address}` const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) const [mobileDrawerAction, setMobileDrawerAction] = useState(WidgetActionType.Deposit) @@ -231,6 +338,7 @@ function Index(): ReactElement | null { const mobileDrawerPanelRef = useRef(null) const detailsRef = useRef(null) const headerRef = useRef(null) + const compressedHeaderMeasureRef = useRef(null) const sectionSelectorRef = useRef(null) const widgetRef = useRef(null) const widgetContainerRef = useRef(null) @@ -269,9 +377,9 @@ function Index(): ReactElement | null { const [activeSection, setActiveSection] = useState('charts') const [sectionScrollOffset, setSectionScrollOffset] = useState(0) const [isHeaderCompressed, setIsHeaderCompressed] = useState(false) - const initialHeaderOffsetRef = useRef(null) + const [yvUsdApyVariant, setYvUsdApyVariant] = useState('locked') + const isCollapsibleMode = headerDisplayMode === 'collapsible' const scrollPadding = 16 - const widgetBottomPadding = 16 const updateSectionScrollOffset = useCallback((): number => { if (typeof window === 'undefined') return 0 const baseOffset = resolveHeaderOffset() @@ -288,36 +396,11 @@ function Index(): ReactElement | null { const [hasTriggeredVaultListFetch, setHasTriggeredVaultListFetch] = useState(false) useEffect(() => { - void vaultKey - initialHeaderOffsetRef.current = null - if (typeof window !== 'undefined') { - document.documentElement.style.removeProperty('--vault-header-initial-offset') - } - }, [vaultKey]) - - useEffect(() => { - if (typeof window === 'undefined') return - void vaultKey - let frame = 0 - const captureInitialOffset = (): void => { - if (initialHeaderOffsetRef.current !== null) return - if (window.scrollY > 0) return - const baseOffset = resolveHeaderOffset() - const headerHeight = headerRef.current?.getBoundingClientRect().height ?? 0 - if (headerHeight <= 0) { - frame = requestAnimationFrame(captureInitialOffset) - return - } - const paddedOffset = Math.round(baseOffset + headerHeight + widgetBottomPadding) - initialHeaderOffsetRef.current = paddedOffset - document.documentElement.style.setProperty('--vault-header-initial-offset', `${paddedOffset}px`) - } - - frame = requestAnimationFrame(captureInitialOffset) - return (): void => { - if (frame) cancelAnimationFrame(frame) + if (!isLockedYvUsdRoute) { + return } - }, [vaultKey]) + navigate(unlockedYvUsdPath, { replace: true }) + }, [isLockedYvUsdRoute, navigate, unlockedYvUsdPath]) const baseVault = useMemo(() => { if (!params.address) return undefined @@ -325,6 +408,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 { @@ -339,10 +427,11 @@ function Index(): ReactElement | null { const isSnapshotNotFound = (snapshotError as any)?.response?.status === 404 const isYBold = useMemo(() => { + if (isYvUsd) return false if (!baseVault?.address && !params.address) return false const resolvedAddress = toAddress(baseVault?.address ?? params.address ?? '0x') return isAddressEqual(resolvedAddress, YBOLD_VAULT_ADDRESS) - }, [baseVault?.address, params.address]) + }, [isYvUsd, baseVault?.address, params.address]) const yBoldStakingVault = useMemo(() => { if (!isYBold) return undefined @@ -378,7 +467,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(() => { @@ -395,28 +486,42 @@ function Index(): ReactElement | null { }, [snapshotShouldDisableStaking, isFactoryVault]) const currentVault = useMemo(() => { + if (isYvUsd) { + return yvUsdVault ?? yvUsdUnlockedVault ?? yvUsdLockedVault + } if (!vaultViewInput) return undefined return getVaultView(vaultViewInput, mergedSnapshot) - }, [vaultViewInput, mergedSnapshot]) + }, [isYvUsd, yvUsdVault, yvUsdUnlockedVault, yvUsdLockedVault, vaultViewInput, mergedSnapshot]) - const isLoadingVault = !currentVault && (isLoadingSnapshotVault || (isLoadingVaultList && !isSnapshotNotFound)) + const shouldBootstrapYvUsdVaultList = isYvUsd && !hasVaultList && !hasTriggeredVaultListFetch + const isLoadingVault = isYvUsd + ? isLoadingYvUsd || shouldBootstrapYvUsdVaultList + : !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 }) useEffect(() => { - if (hasTriggeredVaultListFetch || hasVaultList || !snapshotVault) { + if (hasTriggeredVaultListFetch || hasVaultList) { + return + } + if (!isYvUsd && !snapshotVault) { return } setHasTriggeredVaultListFetch(true) const frame = requestAnimationFrame(() => enableVaultListFetch()) return () => cancelAnimationFrame(frame) - }, [enableVaultListFetch, hasTriggeredVaultListFetch, hasVaultList, snapshotVault]) + }, [enableVaultListFetch, hasTriggeredVaultListFetch, hasVaultList, isYvUsd, snapshotVault]) const vaultShareBalance = !!address && currentVault?.address && Number.isInteger(currentVault?.chainID) @@ -424,11 +529,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) @@ -439,6 +541,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] @@ -450,7 +558,6 @@ function Index(): ReactElement | null { const [isWidgetWalletOpen, setIsWidgetWalletOpen] = useState(false) const [isWidgetRewardsOpen, setIsWidgetRewardsOpen] = useState(false) const [collapsedWidgetHeight, setCollapsedWidgetHeight] = useState(null) - const [isShortViewport, setIsShortViewport] = useState(false) const [isCompactWidget, setIsCompactWidget] = useState(false) const [shouldShowWidgetRewards, setShouldShowWidgetRewards] = useState(true) const [vaultTourState, setVaultTourState] = useState<{ isOpen: boolean; stepId?: string }>({ isOpen: false }) @@ -461,11 +568,6 @@ function Index(): ReactElement | null { isRewardsOpen: boolean } | null>(null) const tourSectionsRef = useRef | null>(null) - const [depositPrefill, setDepositPrefill] = useState<{ - address: `0x${string}` - chainId: number - amount?: string - } | null>(null) useEffect(() => { setWidgetMode((previous) => (widgetActions.includes(previous) ? previous : widgetActions[0])) @@ -477,16 +579,6 @@ function Index(): ReactElement | null { } }, [mobileDrawerAction, widgetActions]) - useEffect(() => { - if (typeof window === 'undefined') return - const updateViewport = (): void => { - setIsShortViewport(window.innerHeight < 890) - } - updateViewport() - window.addEventListener('resize', updateViewport) - return (): void => window.removeEventListener('resize', updateViewport) - }, []) - useEffect(() => { if (typeof window === 'undefined') return const updateViewport = (): void => { @@ -641,23 +733,6 @@ function Index(): ReactElement | null { setIsWidgetSettingsOpen(false) } - const handleZapTokenSelect = useCallback( - (token: TToken): void => { - if (!widgetActions.includes(WidgetActionType.Deposit)) { - return - } - setIsWidgetSettingsOpen(false) - setIsWidgetWalletOpen(false) - setIsWidgetRewardsOpen(false) - setWidgetMode(WidgetActionType.Deposit) - setDepositPrefill({ - address: toAddress(token.address), - chainId: token.chainID - }) - }, - [widgetActions] - ) - const handleRewardsClaimSuccess = useCallback(() => { if (!currentVault) { return @@ -697,7 +772,9 @@ function Index(): ReactElement | null { key: 'charts' as const, shouldRender: Number.isInteger(chainId), ref: sectionRefs.charts, - content: ( + content: isYvUsd ? ( + + ) : ( } ] - }, [chainId, currentVault, sectionRefs, snapshotVault?.inceptTime]) + }, [chainId, currentVault, isYvUsd, sectionRefs, snapshotVault?.inceptTime]) const renderableSections = useMemo(() => sections.filter((section) => section.shouldRender), [sections]) const sectionTabs = renderableSections.map((section) => ({ @@ -862,6 +939,67 @@ function Index(): ReactElement | null { } }, [updateSectionScrollOffset]) + useLayoutEffect(() => { + if (typeof window === 'undefined') return + + const measuredElement = isCollapsibleMode ? compressedHeaderMeasureRef.current : headerRef.current + const fallbackElement = headerRef.current + let frame = 0 + + const updateCompressedOffset = (): void => { + const baseOffset = resolveHeaderOffset() + const primaryHeight = measuredElement?.getBoundingClientRect().height ?? 0 + const fallbackHeight = fallbackElement?.getBoundingClientRect().height ?? 0 + const nextOffset = resolveDesktopWidgetHeaderOffset({ + baseOffset, + headerHeight: primaryHeight > 0 ? primaryHeight : fallbackHeight, + bottomPadding: DESKTOP_WIDGET_BOTTOM_PADDING_PX + }) + + if (nextOffset === null) { + return + } + + document.documentElement.style.setProperty(DESKTOP_WIDGET_OFFSET_CSS_VAR, `${nextOffset}px`) + } + + const scheduleUpdate = (): void => { + if (frame) cancelAnimationFrame(frame) + frame = requestAnimationFrame(updateCompressedOffset) + } + + updateCompressedOffset() + window.addEventListener('resize', scheduleUpdate) + + if (typeof ResizeObserver === 'undefined') { + return (): void => { + if (frame) cancelAnimationFrame(frame) + window.removeEventListener('resize', scheduleUpdate) + } + } + + const observer = new ResizeObserver(scheduleUpdate) + if (measuredElement) { + observer.observe(measuredElement) + } + if (fallbackElement && fallbackElement !== measuredElement) { + observer.observe(fallbackElement) + } + + return (): void => { + if (frame) cancelAnimationFrame(frame) + observer.disconnect() + window.removeEventListener('resize', scheduleUpdate) + } + }, [currentVault?.address, isCollapsibleMode, vaultKey]) + + useEffect(() => { + return (): void => { + if (typeof window === 'undefined') return + document.documentElement.style.removeProperty(DESKTOP_WIDGET_OFFSET_CSS_VAR) + } + }, []) + const handleSelectSection = useCallback( (key: SectionKey): void => { setActiveSection(key) @@ -942,12 +1080,13 @@ function Index(): ReactElement | null { }, [isMobileDrawerOpen, mobileDrawerAction, hideMobileDrawerTabs]) useEffect(() => { + if (isYvUsd) return if (isMobileDrawerOpen && mobileWidgetRef.current) { mobileWidgetRef.current.setMode(mobileDrawerAction) } - }, [isMobileDrawerOpen, mobileDrawerAction]) + }, [isMobileDrawerOpen, mobileDrawerAction, isYvUsd]) - if (isLoadingVault || !params.address) { + if (isLockedYvUsdRoute || isLoadingVault || !params.address) { return (
@@ -980,6 +1119,8 @@ function Index(): ReactElement | null { ) } + const resolvedCurrentVault = currentVault + function getMobileProductTypeLabel(): string { if (mobileListKind === 'allocator' || mobileListKind === 'strategy') return 'Single Asset' if (mobileListKind === 'legacy') return 'Legacy' @@ -996,15 +1137,97 @@ function Index(): ReactElement | null { } } - const isCollapsibleMode = headerDisplayMode === 'collapsible' const headerStickyTop = 'var(--header-height)' const resolvedWidgetMode = widgetActions.includes(widgetMode) ? widgetMode : widgetActions[0] const shouldCollapseWidgetDetails = isCompactWidget - const mobileListKind = deriveListKind(currentVault) + const mobileListKind = deriveListKind(resolvedCurrentVault) const mobileProductTypeLabel = getMobileProductTypeLabel() const widgetModeLabel = getWidgetModeLabel(resolvedWidgetMode) const collapsedWidgetTitle = isWidgetWalletOpen ? 'My Info' : widgetModeLabel + function renderDetailCharts(chartHeightPx: number, chartHeightMdPx: number): ReactElement { + if (isYvUsd) { + return + } + + return ( + + ) + } + + function renderDesktopWidget(): ReactElement { + if (isYvUsd) { + return ( + + ) + } + + return ( + + ) + } + + function renderMobileWidget(): ReactElement { + if (isYvUsd) { + return ( + + ) + } + + return ( + + ) + } + return (
+ {isCollapsibleMode ? ( + + ) : null} +
+ {isYvUsd ? : null}
- @@ -1081,24 +1336,31 @@ function Index(): ReactElement | null {
- + {isYvUsd ? ( + + ) : ( + + )} {isRetired && retiredVaultAlertMessage ? ( - + + ) : null} + + {shouldShowNonYearnVaultAlert ? ( + ) : null} {Number.isInteger(chainId) && (
- + {renderDetailCharts(180, 230)}
)} @@ -1147,9 +1409,7 @@ function Index(): ReactElement | null { 'order-1 md:order-2', 'md:col-span-7 md:col-start-14 md:sticky pt-4', 'flex flex-col overflow-hidden', - isShortViewport - ? 'md:h-[calc(100vh-0.75rem-(var(--vault-header-height)+20px))] max-h-[calc(100vh-0.75rem-(var(--vault-header-height)+20px))]' - : 'md:h-[calc(100vh-var(--vault-header-initial-offset)-16px)] max-h-[calc(100vh-var(--vault-header-initial-offset)-16px)]' + desktopWidgetHeightClassNames.container )} style={{ top: 'var(--vault-header-height, var(--header-height))' }} > @@ -1157,9 +1417,7 @@ function Index(): ReactElement | null { ref={widgetStackRef} className={cl( 'relative grid w-full min-w-0 flex-1 min-h-0 overflow-hidden', - isShortViewport - ? 'max-h-[calc(100vh-16px-(var(--vault-header-height,var(--header-height))-16px))]' - : 'max-h-[calc(100vh-16px-var(--vault-header-initial-offset))]', + desktopWidgetHeightClassNames.stack, isWidgetRewardsOpen ? 'grid-rows-[auto_minmax(0,1fr)]' : 'grid-rows-[minmax(0,1fr)_auto]' )} style={isWidgetRewardsOpen && collapsedWidgetHeight ? { height: collapsedWidgetHeight } : undefined} @@ -1179,42 +1437,22 @@ function Index(): ReactElement | null { className={cl('flex flex-col min-h-0', isWidgetPanelActive ? 'flex' : 'hidden')} aria-hidden={!isWidgetPanelActive} > - setDepositPrefill(null)} - collapseDetails={shouldCollapseWidgetDetails} - /> + {renderDesktopWidget()}
)}
{shouldShowWidgetRewards ? (
({ address: r.address, @@ -1236,7 +1474,11 @@ function Index(): ReactElement | null {
{isRetired && retiredVaultAlertMessage ? ( - + + ) : null} + + {shouldShowNonYearnVaultAlert ? ( + ) : null} {renderableSections.map((section) => { @@ -1331,25 +1573,10 @@ function Index(): ReactElement | null { isOpen={isMobileDrawerOpen} onClose={() => setIsMobileDrawerOpen(false)} title={currentVault.name} - headerActions={} + headerActions={isYvUsd ? undefined : } panelRef={mobileDrawerPanelRef} > - + {renderMobileWidget()}
diff --git a/src/components/pages/vaults/components/SuggestedVaultCard.tsx b/src/components/pages/vaults/components/SuggestedVaultCard.tsx index f7a6fc815..49ab3cc70 100644 --- a/src/components/pages/vaults/components/SuggestedVaultCard.tsx +++ b/src/components/pages/vaults/components/SuggestedVaultCard.tsx @@ -22,7 +22,15 @@ import { toAddress } from '@shared/utils' import { getNetwork } from '@shared/utils/wagmi' import type { ReactElement } from 'react' -export function SuggestedVaultCard({ vault }: { vault: TKongVaultInput }): ReactElement { +export function SuggestedVaultCard({ + vault, + matchedSymbol, + externalProtocol +}: { + vault: TKongVaultInput + matchedSymbol?: string + externalProtocol?: string +}): ReactElement { const apyData = useVaultApyData(vault) const apyLabel = apyData.mode === 'historical' || apyData.mode === 'noForward' ? '30D APY' : 'Est. APY' const chainID = getVaultChainID(vault) @@ -37,6 +45,7 @@ export function SuggestedVaultCard({ vault }: { vault: TKongVaultInput }): React const listKind = deriveListKind(vault) const isAllocatorVault = listKind === 'allocator' || listKind === 'strategy' const isLegacyVault = listKind === 'legacy' + const productTypeLabel = isAllocatorVault ? 'Single Asset' : isLegacyVault ? 'Legacy' : 'LP Token' const chainDescription = getChainDescription(chainID) const categoryDescription = getCategoryDescription(vaultCategory) @@ -81,7 +90,13 @@ export function SuggestedVaultCard({ vault }: { vault: TKongVaultInput }): React showCollapsedTooltip />
-
+ {matchedSymbol ? ( + + + {externalProtocol ? `You hold ${matchedSymbol} on ${externalProtocol}` : `You hold ${matchedSymbol}`} + + ) : null} +

{apyLabel}

diff --git a/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx b/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx new file mode 100644 index 000000000..48e4920a1 --- /dev/null +++ b/src/components/pages/vaults/components/detail/QuickStatsGrid.test.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { MobileKeyMetrics, YvUsdApyStatBox } from './QuickStatsGrid' + +vi.mock('@shared/contexts/useWeb3', () => ({ + useWeb3: () => ({ + address: undefined, + isActive: false + }) +})) + +vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ + VaultForwardAPY: () =>
{'Default APY'}
+})) + +vi.mock('@pages/vaults/components/table/APYDetailsModal', () => ({ + APYDetailsModal: ({ isOpen, title, children }: { isOpen: boolean; title: string; children: ReactNode }) => + isOpen ? ( +
+

{title}

+ {children} +
+ ) : null +})) + +const TEST_VAULT = { + version: '3.0.0', + chainID: 1, + address: '0x0000000000000000000000000000000000000001', + name: 'Test Vault', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'TKN', + decimals: 6 + }, + tvl: { + tvl: 1234 + } +} + +describe('MobileKeyMetrics', () => { + it('renders a custom APY box override when provided', () => { + const html = renderToStaticMarkup( + {'Custom APY'}
} + /> + ) + + expect(html).toContain('Custom APY') + expect(html).not.toContain('Default APY') + }) +}) + +describe('YvUsdApyStatBox', () => { + it('renders the locked variant by default with the unlocked toggle affordance', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('Locked') + expect(html).toContain('9.00%') + expect(html).toContain('Switch to unlocked APY display') + expect(html).not.toContain('data-testid="apy-modal"') + }) + + it('formats large APY values with the shared significant-digit rules', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('118%') + expect(html).not.toContain('117.77%') + }) + + it('renders the controlled variant when provided', () => { + const html = renderToStaticMarkup( + + ) + + expect(html).toContain('Unlocked') + expect(html).toContain('5.00%') + expect(html).toContain('Switch to locked APY display') + }) + + it('shows the Infinifi points icon whenever yvUSD has points', () => { + const lockedHtml = renderToStaticMarkup( + + ) + const unlockedHtml = renderToStaticMarkup( + + ) + + expect(lockedHtml).toContain('aria-label="Infinifi points"') + expect(unlockedHtml).toContain('aria-label="Infinifi points"') + }) +}) diff --git a/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx b/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx index 7f952c29d..216cc236e 100644 --- a/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx +++ b/src/components/pages/vaults/components/detail/QuickStatsGrid.tsx @@ -1,11 +1,17 @@ +import { APYDetailsModal } from '@pages/vaults/components/table/APYDetailsModal' import type { TVaultForwardAPYHandle } from '@pages/vaults/components/table/VaultForwardAPY' import { VaultForwardAPY } from '@pages/vaults/components/table/VaultForwardAPY' +import { YvUsdApyDetailsContent } from '@pages/vaults/components/yvUSD/YvUsdBreakdown' import { getVaultAPR, getVaultToken, getVaultTVL, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { useVaultApyData } from '@pages/vaults/hooks/useVaultApyData' +import { getYvUsdInfinifiPointsNote, type TYvUsdVariant } from '@pages/vaults/utils/yvUsd' import { useWeb3 } from '@shared/contexts/useWeb3' +import { IconInfinifiPoints } from '@shared/icons/IconInfinifiPoints' +import { IconLock } from '@shared/icons/IconLock' +import { IconLockOpen } from '@shared/icons/IconLockOpen' import { cl, formatApyDisplay, toNormalizedBN } from '@shared/utils' -import type { KeyboardEvent, ReactElement, ReactNode } from 'react' -import { useRef } from 'react' +import type { KeyboardEvent, MouseEvent, ReactElement, ReactNode } from 'react' +import { useRef, useState } from 'react' interface StatCardProps { label: string @@ -134,45 +140,56 @@ interface MobileKeyMetricsProps { currentVault: TKongVaultInput showSectionNav?: boolean depositedValue?: bigint + depositedUsdValue?: number tokenPrice: number + apyBox?: ReactElement } export function MobileKeyMetrics({ currentVault, showSectionNav = true, depositedValue, - tokenPrice + depositedUsdValue, + tokenPrice, + apyBox }: MobileKeyMetricsProps): ReactElement { const { address, isActive } = useWeb3() const forwardApyRef = useRef(null) const token = getVaultToken(currentVault) const tvl = getVaultTVL(currentVault) + const hasDepositedUsdOverride = isActive && address && typeof depositedUsdValue === 'number' && depositedUsdValue > 0 const hasVaultBalance = isActive && address && depositedValue !== undefined && depositedValue > 0n const depositedAmount = toNormalizedBN(depositedValue ?? 0n, token.decimals) - const depositUsdValue = depositedAmount.normalized * tokenPrice - const depositValue = hasVaultBalance ? formatUSD(depositUsdValue) : '$0.00' + const resolvedDepositedUsdValue = depositedAmount.normalized * tokenPrice + const depositValue = hasDepositedUsdOverride + ? formatUSD(depositedUsdValue) + : hasVaultBalance + ? formatUSD(resolvedDepositedUsdValue) + : '$0.00' return (
- forwardApyRef.current?.openModal()} - value={ - - } - /> + {apyBox ?? ( + forwardApyRef.current?.openModal()} + value={ + + } + /> + )}
@@ -186,3 +203,98 @@ export function MobileKeyMetrics({
) } + +export function YvUsdApyStatBox({ + lockedApy, + unlockedApy, + activeVariant, + onVariantChange, + lockedHasInfinifiPoints = false, + unlockedHasInfinifiPoints = false +}: { + lockedApy: number + unlockedApy: number + activeVariant?: TYvUsdVariant + onVariantChange?: (variant: TYvUsdVariant) => void + lockedHasInfinifiPoints?: boolean + unlockedHasInfinifiPoints?: boolean +}): ReactElement { + const [internalVariant, setInternalVariant] = useState('locked') + const [isModalOpen, setIsModalOpen] = useState(false) + const isControlledVariant = activeVariant !== undefined + const apyVariant = isControlledVariant ? activeVariant : internalVariant + const isLockedVariant = apyVariant === 'locked' + const selectedApy = isLockedVariant ? lockedApy : unlockedApy + const selectedLabel = isLockedVariant ? 'Locked' : 'Unlocked' + const toggleLabel = isLockedVariant ? 'Switch to unlocked APY display' : 'Switch to locked APY display' + const hasInfinifiPoints = lockedHasInfinifiPoints || unlockedHasInfinifiPoints + const infinifiPointsNote = hasInfinifiPoints ? getYvUsdInfinifiPointsNote() : undefined + + const handleToggle = (event: MouseEvent): void => { + event.preventDefault() + event.stopPropagation() + const nextVariant = apyVariant === 'locked' ? 'unlocked' : 'locked' + if (isControlledVariant) { + onVariantChange?.(nextVariant) + return + } + setInternalVariant(nextVariant) + } + + const handleCardKeyDown = (event: KeyboardEvent): void => { + if (event.target !== event.currentTarget) { + return + } + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + event.preventDefault() + setIsModalOpen(true) + } + + return ( + <> + {/* biome-ignore lint/a11y/useSemanticElements: this card opens a modal while containing a nested variant toggle button */} +
setIsModalOpen(true)} + onKeyDown={handleCardKeyDown} + > +

{'Est. APY'}

+
+ +
+ + {hasInfinifiPoints ? ( + + ) : null} + {formatApyDisplay(selectedApy)} + + {selectedLabel} +
+
+
+ setIsModalOpen(false)} title={'yvUSD APY'}> + + + + ) +} diff --git a/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx b/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx index 232c7f902..2bc8aba23 100755 --- a/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx +++ b/src/components/pages/vaults/components/detail/VaultDetailsHeader.tsx @@ -3,10 +3,15 @@ import { VaultForwardAPY } from '@pages/vaults/components/table/VaultForwardAPY' import { VaultHistoricalAPY } from '@pages/vaults/components/table/VaultHistoricalAPY' import { VaultTVL } from '@pages/vaults/components/table/VaultTVL' import { WidgetTabs } from '@pages/vaults/components/widget' +import { YvUsdApyTooltipContent, YvUsdTvlTooltipContent } from '@pages/vaults/components/yvUSD/YvUsdBreakdown' +import { YvUsdHeaderBanner } from '@pages/vaults/components/yvUSD/YvUsdHeaderBanner' import { getVaultView, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' import { useHeaderCompression } from '@pages/vaults/hooks/useHeaderCompression' +import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import type { WidgetActionType } from '@pages/vaults/types' import { deriveListKind } from '@pages/vaults/utils/vaultListFacets' +import { getVaultPrimaryLogoSrc } from '@pages/vaults/utils/vaultLogo' import { getCategoryDescription, getChainDescription, @@ -15,6 +20,15 @@ import { MIGRATABLE_TAG_DESCRIPTION, RETIRED_TAG_DESCRIPTION } from '@pages/vaults/utils/vaultTagCopy' +import { + getYvUsdInfinifiPointsNote, + getYvUsdSharePrice, + isYvUsdVault, + type TYvUsdVariant, + YVUSD_CHAIN_ID, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' import { METRIC_FOOTNOTE_CLASS, METRIC_VALUE_CLASS, @@ -24,56 +38,125 @@ import { } from '@shared/components/MetricsCard' import { RenderAmount } from '@shared/components/RenderAmount' import { TokenLogo } from '@shared/components/TokenLogo' +import { Tooltip } from '@shared/components/Tooltip' +import { useWeb3 } from '@shared/contexts/useWeb3' +import { useYearn } from '@shared/contexts/useYearn' +import { IconInfinifiPoints } from '@shared/icons/IconInfinifiPoints' import { IconLinkOut } from '@shared/icons/IconLinkOut' -import { cl, formatUSD, SELECTOR_BAR_STYLES, toNormalizedBN } from '@shared/utils' +import { IconLock } from '@shared/icons/IconLock' +import { IconLockOpen } from '@shared/icons/IconLockOpen' +import { cl, formatApyDisplay, formatUSD, isZero, SELECTOR_BAR_STYLES, toAddress, toNormalizedBN } from '@shared/utils' import { getVaultName } from '@shared/utils/helpers' import { getNetwork } from '@shared/utils/wagmi/utils' import type { ReactElement, Ref } from 'react' import { useEffect, useRef, useState } from 'react' import { Link } from 'react-router' +type TVaultKindType = 'multi' | 'single' | undefined + +function noop(): void {} + +function noopWidgetModeChange(_mode: WidgetActionType): void {} + +function noopSelectSection(_key: string): void {} + +function getVaultProductTypeLabel(listKind: ReturnType): string { + if (listKind === 'allocator' || listKind === 'strategy') { + return 'Single Asset' + } + + if (listKind === 'legacy') { + return 'Legacy' + } + + return 'LP Token' +} + +function getVaultKindType( + kind: string | null | undefined, + listKind: ReturnType +): TVaultKindType { + if (kind === 'Multi Strategy') { + return 'multi' + } + + if (kind === 'Single Strategy') { + return 'single' + } + + if (listKind === 'allocator') { + return 'multi' + } + + if (listKind === 'strategy') { + return 'single' + } + + return undefined +} + +function getVaultKindLabel(kindType: TVaultKindType, fallbackKind: string | null | undefined): string | undefined { + if (kindType === 'multi') { + return 'Allocator' + } + + if (kindType === 'single') { + return 'Strategy' + } + + return fallbackKind ?? undefined +} + +function getVaultLogoSize({ isCompressed, isYvUsd }: { isCompressed: boolean; isYvUsd: boolean }): number { + if (isYvUsd) { + return 40 + } + + return isCompressed ? 32 : 40 +} + +function getVaultLogoContainerSizeClassName({ + isCompressed, + isYvUsd +}: { + isCompressed: boolean + isYvUsd: boolean +}): string { + if (isYvUsd || !isCompressed) { + return 'size-10' + } + + return 'size-8' +} + +function getYvUsdHistoricalValue(monthAgo: number, weekAgo: number): number { + return isZero(monthAgo) ? weekAgo : monthAgo +} + function VaultHeaderIdentity({ currentVault: currentVaultInput, isCompressed, - className + className, + includeTourAttributes = true }: { currentVault: TKongVaultInput isCompressed: boolean className?: string + includeTourAttributes?: boolean }): ReactElement { const currentVault = getVaultView(currentVaultInput) const chainName = getNetwork(currentVault.chainID).name - const tokenLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/tokens/${ - currentVault.chainID - }/${currentVault.token.address.toLowerCase()}/logo-128.png` + const isYvUsd = isYvUsdVault(currentVault) + const tokenLogoSrc = getVaultPrimaryLogoSrc(currentVault) const chainLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${currentVault.chainID}/logo-32.png` const explorerBase = getNetwork(currentVault.chainID).defaultBlockExplorer const explorerHref = explorerBase ? `${explorerBase}/address/${currentVault.address}` : '' const showChainChip = !isCompressed const showCategoryChip = Boolean(currentVault.category) const listKind = deriveListKind(currentVault) - const isAllocatorVault = listKind === 'allocator' || listKind === 'strategy' - const isLegacyVault = listKind === 'legacy' - const productTypeLabel = isAllocatorVault ? 'Single Asset' : isLegacyVault ? 'Legacy' : 'LP Token' - - const baseKindType: 'multi' | 'single' | undefined = ((): 'multi' | 'single' | undefined => { - if (currentVault.kind === 'Multi Strategy') return 'multi' - if (currentVault.kind === 'Single Strategy') return 'single' - return undefined - })() - - const fallbackKindType: 'multi' | 'single' | undefined = ((): 'multi' | 'single' | undefined => { - if (listKind === 'allocator') return 'multi' - if (listKind === 'strategy') return 'single' - return undefined - })() - const kindType = baseKindType ?? fallbackKindType - - const kindLabel: string | undefined = ((): string | undefined => { - if (kindType === 'multi') return 'Allocator' - if (kindType === 'single') return 'Strategy' - return currentVault.kind - })() + const productTypeLabel = getVaultProductTypeLabel(listKind) + const kindType = getVaultKindType(currentVault.kind, listKind) + const kindLabel = getVaultKindLabel(kindType, currentVault.kind) const chainDescription = getChainDescription(currentVault.chainID) const categoryDescription = getCategoryDescription(currentVault.category) const productTypeDescription = getProductTypeDescription(listKind) @@ -87,6 +170,8 @@ function VaultHeaderIdentity({ const [isTitleClipped, setIsTitleClipped] = useState(false) const titleRef = useRef(null) const vaultName = getVaultName(currentVault) + const tokenLogoSize = getVaultLogoSize({ isCompressed, isYvUsd }) + const tokenLogoContainerSizeClassName = getVaultLogoContainerSizeClassName({ isCompressed, isYvUsd }) useEffect(() => { // Preload chain logo so it appears instantly when the chip mounts @@ -116,20 +201,20 @@ function VaultHeaderIdentity({ return (
{isCompressed ? (
void sectionSelectorRef?: Ref sectionTabs: { key: string; label: string }[] isCompressed: boolean + includeTourAttributes?: boolean }): ReactElement { return (
void }): ReactElement { const currentVault = getVaultView(currentVaultInput) const totalAssets = toNormalizedBN(currentVault.tvl.totalAssets, currentVault.decimals).normalized const listKind = deriveListKind(currentVault) const isFactoryVault = listKind === 'factory' + const isYvUsd = isYvUsdVault(currentVault) + const [internalYvUsdApyVariant, setInternalYvUsdApyVariant] = useState('locked') + const { metrics: yvUsdMetrics, unlockedVault, lockedVault } = useYvUsdVaults() + const isControlledYvUsdApyVariant = controlledYvUsdApyVariant !== undefined + const yvUsdApyVariant = isControlledYvUsdApyVariant ? controlledYvUsdApyVariant : internalYvUsdApyVariant + const unlockedForwardApy = + yvUsdMetrics?.unlocked.apy ?? (currentVault.apr?.forwardAPR?.netAPR || currentVault.apr?.netAPR || 0) + const lockedForwardApy = yvUsdMetrics?.locked.apy ?? lockedVault?.apr?.forwardAPR?.netAPR ?? 0 + const unlockedMonthly = unlockedVault?.apr?.points?.monthAgo ?? currentVault.apr.points.monthAgo + const unlockedWeekly = unlockedVault?.apr?.points?.weekAgo ?? currentVault.apr.points.weekAgo + const unlockedHistorical = getYvUsdHistoricalValue(unlockedMonthly, unlockedWeekly) + const lockedMonthly = lockedVault?.apr?.points?.monthAgo ?? 0 + const lockedWeekly = lockedVault?.apr?.points?.weekAgo ?? 0 + const lockedHistorical = getYvUsdHistoricalValue(lockedMonthly, lockedWeekly) + const unlockedTvl = unlockedVault?.tvl?.tvl ?? yvUsdMetrics?.unlocked.tvl ?? 0 + const lockedTvl = lockedVault?.tvl?.tvl ?? yvUsdMetrics?.locked.tvl ?? 0 + const combinedTvl = currentVault.tvl?.tvl ?? unlockedTvl + lockedTvl + const isLockedApyVariant = yvUsdApyVariant === 'locked' + const selectedForwardApy = isLockedApyVariant ? lockedForwardApy : unlockedForwardApy + const selectedHistoricalApy = isLockedApyVariant ? lockedHistorical : unlockedHistorical + const hasInfinifiPoints = Boolean(yvUsdMetrics?.locked.hasInfinifiPoints || yvUsdMetrics?.unlocked.hasInfinifiPoints) + const infinifiPointsNote = hasInfinifiPoints ? getYvUsdInfinifiPointsNote() : undefined + const selectedApyIcon = isLockedApyVariant ? ( + + ) : ( + + ) + const apyToggleLabel = isLockedApyVariant ? 'Switch to unlocked APY display' : 'Switch to locked APY display' + const toggleApyVariant = (): void => { + const nextVariant = yvUsdApyVariant === 'locked' ? 'unlocked' : 'locked' + if (isControlledYvUsdApyVariant) { + onYvUsdApyVariantChange?.(nextVariant) + return + } + setInternalYvUsdApyVariant(nextVariant) + } + const renderYvUsdApyValue = (value: number): ReactElement => ( + + + {hasInfinifiPoints ? : null} + {formatApyDisplay(value)} + + ) + const yvUsdEstApyTooltip = isYvUsd ? ( + + ) : undefined + const yvUsdHistoricalApyTooltip = isYvUsd ? ( + + ) : undefined + const yvUsdTvlTooltip = isYvUsd ? ( + + ) : undefined const metrics: TMetricBlock[] = [ { key: 'est-apy', header: , - value: ( + value: isYvUsd ? ( + + {renderYvUsdApyValue(selectedForwardApy)} + + ) : ( , - value: ( + value: isYvUsd ? ( + + {renderYvUsdApyValue(selectedHistoricalApy)} + + ) : ( , - value: , - footnote: ( + value: isYvUsd ? ( + + + + ) : ( + + ), + footnote: isYvUsd ? ( + yvUsdTvlTooltip + ) : (

+

+ ), + value: ( + + {formatUSD(totalValueUsd)} + + ), + footnote: ( +
+
+ + + {'Locked Deposits'} + + +
+
+ + + {'Unlocked Deposits'} + + +
+
+ ) + } + ] + + return ( +
+ +
+ ) +} + function UserHoldingsCard({ currentVault: currentVaultInput, depositedValue, tokenPrice, - isCompressed + isCompressed, + includeTourAttributes = true }: { currentVault: TKongVaultInput depositedValue: bigint tokenPrice: number isCompressed: boolean + includeTourAttributes?: boolean }): ReactElement { const currentVault = getVaultView(currentVaultInput) + if (isYvUsdVault(currentVault)) { + return + } + const depositedAmount = toNormalizedBN(depositedValue, currentVault.token.decimals) const depositedValueUSD = depositedAmount.normalized * tokenPrice const sections: TMetricBlock[] = [ @@ -422,7 +725,7 @@ function UserHoldingsCard({ ] return ( -
+
void @@ -461,30 +749,42 @@ export function VaultDetailsHeader({ widgetActions?: WidgetActionType[] widgetMode?: WidgetActionType onWidgetModeChange?: (mode: WidgetActionType) => void - onCompressionChange?: (isCompressed: boolean) => void + onYvUsdApyVariantChange?: (variant: TYvUsdVariant) => void onWidgetWalletOpen?: () => void isWidgetWalletOpen?: boolean onWidgetCloseOverlays?: () => void -}): ReactElement { - const currentVault = getVaultView(currentVaultInput) - const [forceCompressed, setForceCompressed] = useState(false) - const { isCompressed } = useHeaderCompression({ enabled: isCollapsibleMode, forceCompressed }) - - useEffect(() => { - if (typeof window === 'undefined') return - const updateViewport = (): void => { - setForceCompressed(window.innerHeight < 890) - } - updateViewport() - window.addEventListener('resize', updateViewport) - return (): void => window.removeEventListener('resize', updateViewport) - }, []) +} - useEffect(() => { - onCompressionChange?.(isCompressed) - }, [isCompressed, onCompressionChange]) +type TVaultDetailsHeaderPresentationProps = TVaultDetailsHeaderBaseProps & { + isCompressed: boolean + includeTourAttributes?: boolean +} +export function VaultDetailsHeaderPresentation({ + currentVault: currentVaultInput, + depositedValue, + yvUsdApyVariant, + sectionTabs = [], + activeSectionKey, + onSelectSection, + sectionSelectorRef, + widgetActions = [], + widgetMode, + onWidgetModeChange, + onYvUsdApyVariantChange, + onWidgetWalletOpen, + isWidgetWalletOpen, + onWidgetCloseOverlays, + isCompressed, + includeTourAttributes = true +}: TVaultDetailsHeaderPresentationProps): ReactElement { + const currentVault = getVaultView(currentVaultInput) const tokenPrice = currentVault.tvl.price || 0 + const isYvUsd = isYvUsdVault(currentVault) + const handleSelectSection = onSelectSection ?? noopSelectSection + const handleWidgetModeChange = onWidgetModeChange ?? noopWidgetModeChange + const handleWidgetWalletOpen = onWidgetWalletOpen ?? noop + const handleWidgetCloseOverlays = onWidgetCloseOverlays ?? noop return (
- +
{sectionTabs.length > 0 ? (
) : null} @@ -534,25 +842,47 @@ export function VaultDetailsHeader({
) : ( <> - + {isYvUsd ? ( +
+ +
+ +
+
+ ) : ( + + )}
{' '} {/* step 2 should be here*/}
- +
{sectionTabs.length > 0 ? ( ) : null}
@@ -573,20 +903,50 @@ export function VaultDetailsHeader({ depositedValue={depositedValue} tokenPrice={tokenPrice} isCompressed={isCompressed} + includeTourAttributes={includeTourAttributes} /> {widgetActions.length > 0 && widgetMode && onWidgetModeChange ? ( ) : null}
) } + +export function VaultDetailsHeader({ + isCollapsibleMode = true, + onCompressionChange, + ...presentationProps +}: TVaultDetailsHeaderBaseProps & { + isCollapsibleMode?: boolean + onCompressionChange?: (isCompressed: boolean) => void +}): ReactElement { + const [forceCompressed, setForceCompressed] = useState(false) + const { isCompressed } = useHeaderCompression({ enabled: isCollapsibleMode, forceCompressed }) + + useEffect(() => { + if (typeof window === 'undefined') return + const updateViewport = (): void => { + setForceCompressed(window.innerHeight < 890) + } + updateViewport() + window.addEventListener('resize', updateViewport) + return (): void => window.removeEventListener('resize', updateViewport) + }, []) + + useEffect(() => { + onCompressionChange?.(isCompressed) + }, [isCompressed, onCompressionChange]) + + return +} diff --git a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx index bac7f71a3..a91e7a8dc 100644 --- a/src/components/pages/vaults/components/detail/VaultInfoSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultInfoSection.tsx @@ -9,7 +9,9 @@ import { isAutomatedVault, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' +import { isYvUsdAddress, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' import { IconCopy } from '@shared/icons/IconCopy' import { IconLinkOut } from '@shared/icons/IconLinkOut' import { baseFetcher, isCurveHostUrl, isZeroAddress, normalizeCurveUrl, toAddress, truncateHex } from '@shared/utils' @@ -33,34 +35,73 @@ type TCurvePoolsApiResponse = { const CURVE_POOLS_CACHE_TTL_MS = 30 * 60 * 1000 const CURVE_POOLS_CACHE_GC_MS = 60 * 60 * 1000 const CURVE_POOLS_ENDPOINT = 'https://api.curve.finance/v1/getPools/all' +const INFO_LABEL_CLASS = 'w-full text-sm text-text-secondary md:w-auto md:pr-4' -export const extractCurvePools = (payload: unknown): TCurvePoolEntry[] => { +export function extractCurvePools(payload: unknown): TCurvePoolEntry[] { const poolData = (payload as TCurvePoolsApiResponse | null)?.data?.poolData return Array.isArray(poolData) ? (poolData as TCurvePoolEntry[]) : [] } -export const resolveCurveDepositUrl = (pools: TCurvePoolEntry[], tokenAddress: string): string => { +function normalizeCurvePoolAddress(address?: string): string | null { + return typeof address === 'string' ? toAddress(address) : null +} + +export function resolveCurveDepositUrl(pools: TCurvePoolEntry[], tokenAddress: string): string { const normalizedTarget = toAddress(tokenAddress) if (isZeroAddress(normalizedTarget)) { return '' } for (const pool of pools) { - const poolAddress = typeof pool?.address === 'string' ? toAddress(pool.address) : null - const poolLpAddress = typeof pool?.lpTokenAddress === 'string' ? toAddress(pool.lpTokenAddress) : null + const poolAddress = normalizeCurvePoolAddress(pool.address) + const poolLpAddress = normalizeCurvePoolAddress(pool.lpTokenAddress) if (poolAddress !== normalizedTarget && poolLpAddress !== normalizedTarget) { continue } - const urls = pool.poolUrls?.deposit ?? [] - if (Array.isArray(urls) && typeof urls[0] === 'string') { - return normalizeCurveUrl(urls[0]) + const [depositUrl] = pool.poolUrls?.deposit ?? [] + if (typeof depositUrl === 'string') { + return normalizeCurveUrl(depositUrl) } } return '' } +function getLiquidityUrl({ + isVelodrome, + isAerodrome, + tokenAddress +}: { + isVelodrome: boolean + isAerodrome: boolean + tokenAddress: string +}): string { + if (isVelodrome) { + return `https://velodrome.finance/liquidity?query=${tokenAddress}` + } + + if (isAerodrome) { + return `https://aerodrome.finance/liquidity?query=${tokenAddress}` + } + + return '' +} + +function getDeployedLabel(inceptTime?: number | null): string | null { + if (typeof inceptTime !== 'number' || !Number.isFinite(inceptTime) || inceptTime <= 0) { + return null + } + + const timestampMs = inceptTime > 1_000_000_000_000 ? inceptTime : inceptTime * 1000 + const deployedDate = new Date(timestampMs) + if (Number.isNaN(deployedDate.getTime())) { + return null + } + + return deployedDate.toLocaleString(undefined, { day: 'numeric', month: 'long', year: 'numeric' }) +} + function AddressLink({ address, explorerUrl, @@ -72,7 +113,7 @@ function AddressLink({ }): ReactElement { return (
-

{label}

+

{label}

+
+

{'Unlocked Price Per Share'}

+

+ {unlockedPricePerShare} +

+
+
+

{'Locked Price Per Share'}

+

+ {lockedPricePerShare} +

+
+ + ) +} + export function VaultInfoSection({ currentVault, inceptTime @@ -106,6 +170,7 @@ export function VaultInfoSection({ }): ReactElement { const chainID = getVaultChainID(currentVault) const vaultAddress = getVaultAddress(currentVault) + const isYvUsd = isYvUsdAddress(vaultAddress) const token = getVaultToken(currentVault) const staking = getVaultStaking(currentVault) const info = getVaultInfo(currentVault) @@ -113,6 +178,7 @@ export function VaultInfoSection({ const category = getVaultCategory(currentVault) const blockExplorer = getNetwork(chainID).blockExplorers?.etherscan?.url || getNetwork(chainID).blockExplorers?.default.url + const explorerUrl = blockExplorer || '' const sourceUrl = String(info?.sourceURL || '') const sourceUrlLower = sourceUrl.toLowerCase() const isVelodrome = category?.toLowerCase() === 'velodrome' || sourceUrlLower.includes('velodrome.finance') @@ -134,38 +200,39 @@ export function VaultInfoSection({ }) const curveSourceUrl = isCurveCategory && isCurveHostUrl(sourceUrl) ? normalizeCurveUrl(sourceUrl) : '' const resolvedCurvePoolUrl = curvePoolUrl || curveSourceUrl - const liquidityUrl = isVelodrome - ? `https://velodrome.finance/liquidity?query=${token.address}` - : isAerodrome - ? `https://aerodrome.finance/liquidity?query=${token.address}` - : '' + const liquidityUrl = getLiquidityUrl({ isVelodrome, isAerodrome, tokenAddress: token.address }) const powergloveUrl = `https://powerglove.yearn.fi/vaults/${chainID}/${vaultAddress}` - const deployedLabel = (() => { - if (typeof inceptTime !== 'number' || !Number.isFinite(inceptTime) || inceptTime <= 0) { - return null - } - const ms = inceptTime > 1_000_000_000_000 ? inceptTime : inceptTime * 1000 - const date = new Date(ms) - if (Number.isNaN(date.getTime())) { - return null - } - return date.toLocaleString(undefined, { day: 'numeric', month: 'long', year: 'numeric' }) - })() + const deployedLabel = getDeployedLabel(inceptTime) return (
- + {isYvUsd ? ( + <> + + + + ) : ( + + )} - + {staking.available ? ( - + ) : null} {resolvedCurvePoolUrl ? (
-

{'Curve Pool URL'}

+

{'Curve Pool URL'}

-

{'Gamma Pair'}

+

{'Gamma Pair'}

-

- {isVelodrome ? 'Velodrome Pool URL' : 'Aerodrome Pool URL'} -

+

{isVelodrome ? 'Velodrome Pool URL' : 'Aerodrome Pool URL'}

) : null} -
-

{'Current Price Per Share'}

-

- {apr.pricePerShare.today} -

-
+ {isYvUsd ? ( + + ) : ( +
+

{'Current Price Per Share'}

+

+ {apr.pricePerShare.today} +

+
+ )} {deployedLabel ? (
-

{'Deployed on'}

+

{'Deployed on'}

{deployedLabel}

@@ -240,7 +309,7 @@ export function VaultInfoSection({ ) : null}
-

{'Powerglove Analytics Page'}

+

{'Powerglove Analytics Page'}

-

{'Vault Snapshot Data'}

+

{'Vault Snapshot Data'}

void @@ -17,12 +19,15 @@ type TRiskScoreItem = { function RiskScoreItem({ label, score, + scoreSuffix = ' / 5', explanation, isOpen, onToggle, isOverall = false, rightContent = null }: TRiskScoreItem): ReactElement { + const hasScore = score !== undefined && score !== null && score !== '' + return (
@@ -35,10 +40,12 @@ function RiskScoreItem({

{label}

-
-

{score}

- {' / 5'} -
+ {hasScore ? ( +
+

{score}

+ {scoreSuffix ? {scoreSuffix} : null} +
+ ) : null} {rightContent ?
{rightContent}
: null}
{isOpen && ( @@ -52,11 +59,42 @@ function RiskScoreItem({ export function VaultRiskSection({ currentVault }: { currentVault: TKongVaultInput }): ReactElement { const info = getVaultInfo(currentVault) - const hasRiskScore = useMemo(() => (info.riskScore || []).reduce((sum, score) => sum + score, 0), [info.riskScore]) + const hasRiskScore = (info.riskScore || []).reduce((sum, score) => sum + score, 0) + + if (isYvUsdVault(currentVault)) { + return + } return } +function YvUsdRiskScore(): ReactElement { + const [openIndex, setOpenIndex] = useState(null) + + const toggleItem = (index: number): void => { + setOpenIndex((current) => (current === index ? null : index)) + } + + return ( +
+
+ {YVUSD_RISK_SCORE_ITEMS.map((item, index) => ( + toggleItem(index)} + isOverall={item.isOverall} + /> + ))} +
+
+ ) +} + function SimpleRiskScore({ hasRiskScore, currentVault diff --git a/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx b/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx index 046f621b1..1df8e4912 100644 --- a/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx +++ b/src/components/pages/vaults/components/detail/VaultStrategiesSection.tsx @@ -16,9 +16,10 @@ import { DARK_MODE_COLORS, LIGHT_MODE_COLORS, useDarkMode } from '@shared/compon import { useYearn } from '@shared/contexts/useYearn' import { useYearnTokenPrice } from '@shared/hooks/useYearnTokenPrice' import type { TSortDirection } from '@shared/types' -import { cl, formatPercent, formatTvlDisplay, toAddress, toBigInt, toNormalizedBN } from '@shared/utils' +import { cl, formatTvlDisplay, toAddress, toBigInt, toNormalizedBN } from '@shared/utils' import type { ReactElement } from 'react' import { lazy, Suspense, useCallback, useMemo } from 'react' +import { formatStrategiesPercent } from './strategiesPercentFormat' import { VaultsListHead } from './VaultsListHead' import { VaultsListStrategy } from './VaultsListStrategy' @@ -248,7 +249,7 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa {unallocatedPercentage > 0 && unallocatedValue > 0n ? (
-
+
@@ -256,12 +257,12 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa Unallocated
-
-
+
+

Allocation %

-

{formatPercent(unallocatedPercentage / 100)}

+

{formatStrategiesPercent(unallocatedPercentage / 100)}

-
+

Amount

{formatTvlDisplay( @@ -269,7 +270,7 @@ export function VaultStrategiesSection({ currentVault }: { currentVault: TKongVa )}

-
+

APY

-

diff --git a/src/components/pages/vaults/components/detail/VaultsListHead.test.ts b/src/components/pages/vaults/components/detail/VaultsListHead.test.ts new file mode 100644 index 000000000..9ee31f604 --- /dev/null +++ b/src/components/pages/vaults/components/detail/VaultsListHead.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' + +import { STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT } from './strategiesLayout' + +describe('VaultsListHead desktop layout', () => { + it('uses tightened strategy panel desktop column classes', () => { + expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('col-span-11') + expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('col-span-12') + expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2') + expect(STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4') + }) +}) diff --git a/src/components/pages/vaults/components/detail/VaultsListHead.tsx b/src/components/pages/vaults/components/detail/VaultsListHead.tsx index 24af0ce13..c7e326df6 100644 --- a/src/components/pages/vaults/components/detail/VaultsListHead.tsx +++ b/src/components/pages/vaults/components/detail/VaultsListHead.tsx @@ -6,6 +6,7 @@ import { cl } from '@shared/utils' import type { ReactElement } from 'react' import { useCallback, useMemo } from 'react' +import { STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT } from './strategiesLayout' type TSortableListHeadItem = { type?: 'sort' @@ -178,7 +179,7 @@ export function VaultsListHead({ >
-
+
{rest.map( (item): ReactElement => ( -
+
{renderItem(item, !isToggleItem(item) && sortBy === item.value, true)}
) diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts new file mode 100644 index 000000000..fc9a6c07f --- /dev/null +++ b/src/components/pages/vaults/components/detail/VaultsListStrategy.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' + +describe('VaultsListStrategy desktop layout', () => { + it('uses tightened strategy row desktop column classes with balanced desktop title wrapping', () => { + expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameColumnSpanClass).toBe('md:col-span-11') + expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesColumnSpanClass).toBe('md:col-span-12') + expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valuesGridClass).toBe('md:grid-cols-12 md:gap-2') + expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.valueColumnSpanClass).toBe('md:col-span-4') + expect(STRATEGY_PANEL_ROW_DESKTOP_LAYOUT.nameLabelDesktopWrapClass).toBe( + 'md:[display:-webkit-box] md:[-webkit-box-orient:vertical] md:[-webkit-line-clamp:2] md:[text-wrap:balance] md:whitespace-normal' + ) + }) +}) diff --git a/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx b/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx index e321c07d9..033065edb 100644 --- a/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx +++ b/src/components/pages/vaults/components/detail/VaultsListStrategy.tsx @@ -4,13 +4,15 @@ import { IconChevron } from '@shared/icons/IconChevron' import { IconCopy } from '@shared/icons/IconCopy' import { IconLinkOut } from '@shared/icons/IconLinkOut' import type { TAddress } from '@shared/types' -import { cl, formatApyDisplay, formatPercent, toAddress, truncateHex } from '@shared/utils' +import { cl, toAddress, truncateHex } from '@shared/utils' import { formatDuration } from '@shared/utils/format.time' import { copyToClipboard } from '@shared/utils/helpers' import { getNetwork } from '@shared/utils/wagmi/utils' import type { ReactElement } from 'react' import { useState } from 'react' import Link from '/src/components/Link' +import { STRATEGY_PANEL_ROW_DESKTOP_LAYOUT } from './strategiesLayout' +import { formatStrategiesApy, formatStrategiesPercent } from './strategiesPercentFormat' export function VaultsListStrategy({ details, @@ -52,10 +54,10 @@ export function VaultsListStrategy({ if (shouldShowPlaceholders) { apyContent = '-' } else { - apyContent = formatApyDisplay(displayApr) + apyContent = formatStrategiesApy(displayApr) } - const allocationContent = isInactive ? '-' : isUnallocated ? '-' : formatPercent((details?.debtRatio || 0) / 100) + const allocationContent = isInactive || isUnallocated ? '-' : formatStrategiesPercent((details?.debtRatio || 0) / 100) const amountContent = isInactive ? '-' : isUnallocated ? '-' : allocation @@ -64,13 +66,15 @@ export function VaultsListStrategy({ {/* Collapsible header - always visible */}
setIsExpanded(!isExpanded)} > {/* Top row on mobile: Name + Chevron */} -
+
0.01 ? 'bg-green-500' : 'bg-text-secondary')} /> @@ -99,9 +103,17 @@ export function VaultsListStrategy({ className="rounded-full" />
- - {name} - +
+ + {name} + +
{/* Stats row - 3 columns on mobile */} -
-
+
+

{'Allocation %'}

{allocationContent}

-
+

{'Amount'}

{amountContent}

-
+

{'APY'}

{apyContent}

@@ -144,11 +164,11 @@ export function VaultsListStrategy({
Management Fee: - {formatPercent((fees?.management || 0) * 100, 0)} + {formatStrategiesPercent((fees?.management || 0) * 100)}
Performance Fee: - {formatPercent((details?.performanceFee || 0) / 100, 0)} + {formatStrategiesPercent((details?.performanceFee || 0) / 100)}
Last Report: diff --git a/src/components/pages/vaults/components/detail/YvUsdChartsSection.tsx b/src/components/pages/vaults/components/detail/YvUsdChartsSection.tsx new file mode 100644 index 000000000..bc1ced688 --- /dev/null +++ b/src/components/pages/vaults/components/detail/YvUsdChartsSection.tsx @@ -0,0 +1,160 @@ +import { type TYvUsdSeriesPoint, useYvUsdCharts } from '@pages/vaults/hooks/useYvUsdCharts' +import { cl, SELECTOR_BAR_STYLES } from '@shared/utils' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { ChartErrorBoundary } from './charts/ChartErrorBoundary' +import ChartSkeleton from './charts/ChartSkeleton' +import ChartsLoader from './charts/ChartsLoader' +import { FixedHeightChartContainer } from './charts/FixedHeightChartContainer' +import { YvUsdApyChart, YvUsdChartLegend, YvUsdPerformanceChart, YvUsdTvlChart } from './charts/YvUsdDualLineChart' +import { VAULT_CHART_TABS, VAULT_CHART_TIMEFRAME_OPTIONS } from './VaultChartsSection' + +export type TYvUsdChartTab = (typeof VAULT_CHART_TABS)[number]['id'] +export type TYvUsdChartTimeframe = (typeof VAULT_CHART_TIMEFRAME_OPTIONS)[number]['value'] + +type YvUsdChartsSectionProps = { + chartTab?: TYvUsdChartTab + onChartTabChange?: (tab: TYvUsdChartTab) => void + timeframe?: TYvUsdChartTimeframe + onTimeframeChange?: (timeframe: TYvUsdChartTimeframe) => void + shouldRenderSelectors?: boolean + chartHeightPx?: number + chartHeightMdPx?: number +} + +function getActiveChartData({ + activeTab, + apyData, + performanceData, + tvlData +}: { + activeTab: TYvUsdChartTab + apyData?: TYvUsdSeriesPoint[] + performanceData?: TYvUsdSeriesPoint[] + tvlData?: TYvUsdSeriesPoint[] +}): TYvUsdSeriesPoint[] | undefined { + switch (activeTab) { + case 'historical-pps': + return performanceData + case 'historical-tvl': + return tvlData + default: + return apyData + } +} + +function renderActiveChart({ + activeTab, + activeTimeframe, + apyData, + performanceData, + tvlData +}: { + activeTab: TYvUsdChartTab + activeTimeframe: TYvUsdChartTimeframe + apyData?: TYvUsdSeriesPoint[] + performanceData?: TYvUsdSeriesPoint[] + tvlData?: TYvUsdSeriesPoint[] +}): ReactElement | null { + switch (activeTab) { + case 'historical-pps': + return performanceData ? : null + case 'historical-tvl': + return tvlData ? : null + default: + return apyData ? : null + } +} + +export function YvUsdChartsSection({ + chartTab, + onChartTabChange, + timeframe, + onTimeframeChange, + shouldRenderSelectors = true, + chartHeightPx, + chartHeightMdPx +}: YvUsdChartsSectionProps): ReactElement { + const { apyData, performanceData, tvlData, isLoading, error } = useYvUsdCharts() + + const chartsLoading = isLoading || !apyData || !performanceData || !tvlData + const hasError = Boolean(error) + + const [uncontrolledTab, setUncontrolledTab] = useState('historical-apy') + const [uncontrolledTimeframe, setUncontrolledTimeframe] = useState('1y') + + const activeTab = chartTab ?? uncontrolledTab + const activeTimeframe = timeframe ?? uncontrolledTimeframe + const setActiveTab = onChartTabChange ?? setUncontrolledTab + const setActiveTimeframe = onTimeframeChange ?? setUncontrolledTimeframe + const activeChartData = getActiveChartData({ activeTab, apyData, performanceData, tvlData }) + + return ( +
+ {shouldRenderSelectors ? ( +
+
+
+ {VAULT_CHART_TABS.map((tab) => ( + + ))} +
+
+
+
+ {VAULT_CHART_TIMEFRAME_OPTIONS.map((option) => ( + + ))} +
+
+
+ ) : null} + + {hasError ? ( +
+ {'Unable to load chart data right now.'} +
+ ) : chartsLoading ? ( +
+ + +
+ ) : ( +
+ + + {renderActiveChart({ activeTab, activeTimeframe, apyData, performanceData, tvlData })} + + + {activeChartData ? : null} +
+ )} +
+ ) +} diff --git a/src/components/pages/vaults/components/detail/charts/YvUsdDualLineChart.tsx b/src/components/pages/vaults/components/detail/charts/YvUsdDualLineChart.tsx new file mode 100644 index 000000000..7e19faac8 --- /dev/null +++ b/src/components/pages/vaults/components/detail/charts/YvUsdDualLineChart.tsx @@ -0,0 +1,251 @@ +import type { TYvUsdSeriesPoint } from '@pages/vaults/hooks/useYvUsdCharts' +import { + formatChartMonthYearLabel, + formatChartTooltipDate, + formatChartWeekLabel, + getChartMonthlyTicks, + getChartWeeklyTicks, + getTimeframeLimit +} from '@pages/vaults/utils/charts' +import { useChartStyle } from '@shared/contexts/useChartStyle' +import { getChartStyleVariables } from '@shared/utils/chartStyles' +import type { CSSProperties, ReactElement } from 'react' +import { useMemo } from 'react' +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts' +import type { ChartConfig } from './ChartPrimitives' +import { ChartContainer, ChartTooltip } from './ChartPrimitives' +import { + CHART_WITH_AXES_MARGIN, + CHART_Y_AXIS_TICK_MARGIN, + CHART_Y_AXIS_TICK_STYLE, + CHART_Y_AXIS_WIDTH +} from './chartLayout' + +type TYvUsdDualLineChartProps = { + chartData: TYvUsdSeriesPoint[] + timeframe: string + hideTooltip?: boolean + allowNegativeValues?: boolean + formatValue: (value: number) => string + formatTick: (value: number | string) => string +} + +type TYvUsdSeriesKey = 'unlocked' | 'locked' + +const SERIES_CONFIG: ChartConfig = { + unlocked: { + label: 'Unlocked', + color: 'var(--chart-1)' + }, + locked: { + label: 'Locked', + color: 'var(--chart-2)' + } +} + +function getFilteredYvUsdChartData(chartData: TYvUsdSeriesPoint[], timeframe: string): TYvUsdSeriesPoint[] { + const limit = getTimeframeLimit(timeframe) + if (!Number.isFinite(limit) || limit >= chartData.length) { + return chartData + } + return chartData.slice(-limit) +} + +function getVisibleSeriesKeys(chartData: TYvUsdSeriesPoint[], timeframe: string): TYvUsdSeriesKey[] { + const filteredData = getFilteredYvUsdChartData(chartData, timeframe) + return (Object.keys(SERIES_CONFIG) as TYvUsdSeriesKey[]).filter((seriesKey) => + filteredData.some((point) => typeof point[seriesKey] === 'number' && Number.isFinite(point[seriesKey])) + ) +} + +function getSeriesLabel(name: string): string { + return name === 'locked' ? 'Locked' : 'Unlocked' +} + +export function YvUsdDualLineChart({ + chartData, + timeframe, + hideTooltip, + allowNegativeValues = false, + formatValue, + formatTick +}: TYvUsdDualLineChartProps): ReactElement { + const filteredData = useMemo(() => getFilteredYvUsdChartData(chartData, timeframe), [chartData, timeframe]) + const hasNegativeValues = useMemo( + () => + filteredData.some((point) => + (Object.keys(SERIES_CONFIG) as TYvUsdSeriesKey[]).some((seriesKey) => { + const value = point[seriesKey] + return typeof value === 'number' && Number.isFinite(value) && value < 0 + }) + ), + [filteredData] + ) + const isShortTimeframe = timeframe === '30d' || timeframe === '90d' + const ticks = useMemo( + () => (isShortTimeframe ? getChartWeeklyTicks(filteredData) : getChartMonthlyTicks(filteredData)), + [filteredData, isShortTimeframe] + ) + const tickFormatter = isShortTimeframe ? formatChartWeekLabel : formatChartMonthYearLabel + + return ( + + + + + + {!hideTooltip && ( + { + const numericValue = Number(value ?? 0) + return [formatValue(numericValue), getSeriesLabel(name)] + }} + labelFormatter={formatChartTooltipDate} + contentStyle={{ + backgroundColor: 'var(--chart-tooltip-bg)', + borderRadius: 'var(--chart-tooltip-radius)', + border: '1px solid var(--chart-tooltip-border)', + boxShadow: 'var(--chart-tooltip-shadow)' + }} + /> + )} + + + + + ) +} + +export function YvUsdChartLegend({ + chartData, + timeframe +}: Pick): ReactElement | null { + const { chartStyle } = useChartStyle() + const chartStyleVars = getChartStyleVariables(chartStyle) + const visibleSeries = useMemo(() => getVisibleSeriesKeys(chartData, timeframe), [chartData, timeframe]) + + if (visibleSeries.length <= 1) { + return null + } + + return ( +
+
+ {visibleSeries.map((seriesKey) => ( +
+ + {SERIES_CONFIG[seriesKey].label} +
+ ))} +
+
+ ) +} + +function formatPercentTick(value: number | string): string { + const numericValue = Number(value) + if (!Number.isFinite(numericValue) || numericValue === 0) return '' + return `${numericValue.toFixed(1)}%` +} + +function formatPpsTick(value: number | string): string { + const numericValue = Number(value) + if (!Number.isFinite(numericValue) || numericValue === 0) return '' + return numericValue.toFixed(2) +} + +function formatTvlTick(value: number | string): string { + const numericValue = Number(value) + if (!Number.isFinite(numericValue) || numericValue === 0) return '' + return `$${(numericValue / 1_000_000).toFixed(1)}M` +} + +export function YvUsdApyChart({ + chartData, + timeframe, + hideTooltip +}: Omit): ReactElement { + return ( + `${value.toFixed(2)}%`} + formatTick={formatPercentTick} + /> + ) +} + +export function YvUsdPerformanceChart({ + chartData, + timeframe, + hideTooltip +}: Omit): ReactElement { + return ( + value.toFixed(4)} + formatTick={formatPpsTick} + /> + ) +} + +export function YvUsdTvlChart({ + chartData, + timeframe, + hideTooltip +}: Omit): ReactElement { + return ( + + `$${value.toLocaleString(undefined, { + maximumFractionDigits: 0 + })}` + } + formatTick={formatTvlTick} + /> + ) +} diff --git a/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts b/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts new file mode 100644 index 000000000..7d86483cc --- /dev/null +++ b/src/components/pages/vaults/components/detail/desktopWidgetSizing.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { + DESKTOP_WIDGET_OFFSET_CSS_VAR, + getDesktopWidgetHeightClassNames, + resolveDesktopWidgetHeaderOffset +} from './desktopWidgetSizing' + +describe('resolveDesktopWidgetHeaderOffset', () => { + it('rounds the combined base offset, measured header height, and widget padding', () => { + expect(resolveDesktopWidgetHeaderOffset({ baseOffset: 72.2, headerHeight: 180.6 })).toBe(269) + }) + + it('returns null until a positive header height is available', () => { + expect(resolveDesktopWidgetHeaderOffset({ baseOffset: 72, headerHeight: 0 })).toBeNull() + }) +}) + +describe('getDesktopWidgetHeightClassNames', () => { + it('uses the compressed header offset by default for both container and stack sizing', () => { + expect(getDesktopWidgetHeightClassNames()).toEqual({ + container: `md:h-[calc(100vh-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR})-16px)] max-h-[calc(100vh-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR})-16px)]`, + stack: `max-h-[calc(100vh-16px-var(${DESKTOP_WIDGET_OFFSET_CSS_VAR}))]` + }) + }) + + it('supports an alternate offset variable when the layout needs a different measurement source', () => { + expect(getDesktopWidgetHeightClassNames('--vault-header-expanded-offset')).toEqual({ + container: + 'md:h-[calc(100vh-var(--vault-header-expanded-offset)-16px)] max-h-[calc(100vh-var(--vault-header-expanded-offset)-16px)]', + stack: 'max-h-[calc(100vh-16px-var(--vault-header-expanded-offset))]' + }) + }) +}) diff --git a/src/components/pages/vaults/components/detail/desktopWidgetSizing.ts b/src/components/pages/vaults/components/detail/desktopWidgetSizing.ts new file mode 100644 index 000000000..595736ed1 --- /dev/null +++ b/src/components/pages/vaults/components/detail/desktopWidgetSizing.ts @@ -0,0 +1,29 @@ +export const DESKTOP_WIDGET_BOTTOM_PADDING_PX = 16 +const DESKTOP_WIDGET_VIEWPORT_PADDING_PX = 16 +export const DESKTOP_WIDGET_OFFSET_CSS_VAR = '--vault-header-compressed-offset' + +export function resolveDesktopWidgetHeaderOffset({ + baseOffset, + headerHeight, + bottomPadding = DESKTOP_WIDGET_BOTTOM_PADDING_PX +}: { + baseOffset: number + headerHeight: number + bottomPadding?: number +}): number | null { + if (!Number.isFinite(baseOffset) || !Number.isFinite(headerHeight) || headerHeight <= 0) { + return null + } + + return Math.round(baseOffset + headerHeight + bottomPadding) +} + +export function getDesktopWidgetHeightClassNames(offsetCssVar: string = DESKTOP_WIDGET_OFFSET_CSS_VAR): { + container: string + stack: string +} { + return { + container: `md:h-[calc(100vh-var(${offsetCssVar})-${DESKTOP_WIDGET_VIEWPORT_PADDING_PX}px)] max-h-[calc(100vh-var(${offsetCssVar})-${DESKTOP_WIDGET_VIEWPORT_PADDING_PX}px)]`, + stack: `max-h-[calc(100vh-${DESKTOP_WIDGET_VIEWPORT_PADDING_PX}px-var(${offsetCssVar}))]` + } +} diff --git a/src/components/pages/vaults/components/detail/strategiesLayout.ts b/src/components/pages/vaults/components/detail/strategiesLayout.ts new file mode 100644 index 000000000..89f5208dd --- /dev/null +++ b/src/components/pages/vaults/components/detail/strategiesLayout.ts @@ -0,0 +1,15 @@ +export const STRATEGY_PANEL_HEAD_DESKTOP_LAYOUT = { + nameColumnSpanClass: 'col-span-11', + valuesColumnSpanClass: 'col-span-12', + valuesGridClass: 'md:grid-cols-12 md:gap-2', + valueColumnSpanClass: 'md:col-span-4' +} as const + +export const STRATEGY_PANEL_ROW_DESKTOP_LAYOUT = { + nameColumnSpanClass: 'md:col-span-11', + valuesColumnSpanClass: 'md:col-span-12', + valuesGridClass: 'md:grid-cols-12 md:gap-2', + valueColumnSpanClass: 'md:col-span-4', + nameLabelDesktopWrapClass: + 'md:[display:-webkit-box] md:[-webkit-box-orient:vertical] md:[-webkit-line-clamp:2] md:[text-wrap:balance] md:whitespace-normal' +} as const diff --git a/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts b/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts new file mode 100644 index 000000000..896b97150 --- /dev/null +++ b/src/components/pages/vaults/components/detail/strategiesPercentFormat.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' + +import { formatStrategiesApy, formatStrategiesPercent } from './strategiesPercentFormat' + +describe('strategiesPercentFormat', () => { + it('pads percentages to the strategy card precision rules', () => { + expect(formatStrategiesPercent(12.34)).toBe('12.3%') + expect(formatStrategiesPercent(13)).toBe('13.0%') + expect(formatStrategiesPercent(5.2)).toBe('5.20%') + expect(formatStrategiesPercent(0)).toBe('0.00%') + }) + + it('limits sub-1 percentages and apy to two decimal places', () => { + expect(formatStrategiesPercent(0.9876)).toBe('0.99%') + expect(formatStrategiesApy(0.00262)).toBe('0.26%') + }) + + it('applies upper-limit formatting for percentages at or above threshold', () => { + expect(formatStrategiesPercent(501)).toBe('≥ 500%') + }) + + it('formats infinity values for percentage and APY', () => { + expect(formatStrategiesPercent(Infinity)).toBe('∞%') + expect(formatStrategiesApy(Infinity)).toBe('∞%') + }) +}) diff --git a/src/components/pages/vaults/components/detail/strategiesPercentFormat.ts b/src/components/pages/vaults/components/detail/strategiesPercentFormat.ts new file mode 100644 index 000000000..2ed3e5866 --- /dev/null +++ b/src/components/pages/vaults/components/detail/strategiesPercentFormat.ts @@ -0,0 +1,60 @@ +type TStrategiesPercentFormatOptions = { + locales?: string[] + upperLimit?: number +} + +function resolveLocales(options?: TStrategiesPercentFormatOptions): string[] { + const locales: string[] = [] + if (options?.locales) { + locales.push(...options.locales) + } + if (typeof navigator !== 'undefined') { + locales.push(navigator.language || 'en-US') + } + locales.push('en-US') + return locales +} + +function resolveFractionDigits(value: number): number { + const absoluteValue = Math.abs(value) + if (absoluteValue >= 100) { + return 0 + } + if (absoluteValue >= 10) { + return 1 + } + return 2 +} + +function formatWithPaddedFractionDigits(value: number, options?: TStrategiesPercentFormatOptions): string { + const fractionDigits = resolveFractionDigits(value) + return new Intl.NumberFormat(resolveLocales(options), { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits + }).format(value) +} + +export function formatStrategiesPercent(value: number, options?: TStrategiesPercentFormatOptions): string { + if (value === Infinity || value === -Infinity) { + return '∞%' + } + + const safeValue = Number.isFinite(value) ? value : 0 + const upperLimit = options?.upperLimit ?? 500 + if (safeValue >= upperLimit) { + return `≥ ${formatWithPaddedFractionDigits(upperLimit, options)}%` + } + return `${formatWithPaddedFractionDigits(safeValue, options)}%` +} + +export function formatStrategiesApy( + value: number | null | undefined, + options?: TStrategiesPercentFormatOptions +): string { + if (value === Infinity || value === -Infinity) { + return '∞%' + } + const numericValue = typeof value === 'number' ? value : 0 + const safeValue = Number.isFinite(numericValue) ? numericValue : 0 + return formatStrategiesPercent(safeValue * 100, options) +} diff --git a/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx b/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx index bdf4d6770..cea4fff6c 100644 --- a/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx +++ b/src/components/pages/vaults/components/list/VaultsAuxiliaryList.tsx @@ -2,7 +2,7 @@ import { VaultsListRow } from '@pages/vaults/components/list/VaultsListRow' import { VirtualizedVaultsList } from '@pages/vaults/components/list/VirtualizedVaultsList' import type { TVaultForwardAPYVariant } from '@pages/vaults/components/table/VaultForwardAPY' import { getVaultAddress, getVaultChainID, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { toAddress } from '@shared/utils' +import { cl, toAddress } from '@shared/utils' import type { ReactElement } from 'react' @@ -36,6 +36,10 @@ type TVaultsAuxiliaryListProps = { onExpandedChange?: (vaultKey: string, next: boolean) => void } +function getVaultListKey(vault: TKongVaultInput): string { + return `${getVaultChainID(vault)}_${toAddress(getVaultAddress(vault))}` +} + // TODO: the contents of this component override the type filers. This should only happen for HOLDINGS and not AVAILABLE TO DEPOSIT export function VaultsAuxiliaryList({ title, @@ -62,7 +66,7 @@ export function VaultsAuxiliaryList({ } return ( -
+
{title ? (

{title}

) : null} @@ -70,9 +74,9 @@ export function VaultsAuxiliaryList({ items={vaults} estimateSize={81} itemSpacingClassName={'pb-px'} - getItemKey={(vault): string => `${getVaultChainID(vault)}_${toAddress(getVaultAddress(vault))}`} + getItemKey={getVaultListKey} renderItem={(vault): ReactElement => { - const key = `${getVaultChainID(vault)}_${toAddress(getVaultAddress(vault))}` + const key = getVaultListKey(vault) const rowApyDisplayVariant = resolveApyDisplayVariant?.(vault) ?? apyDisplayVariant const isExpanded = expandedVaultKeys ? Boolean(expandedVaultKeys[key]) : undefined const handleExpandedChange = onExpandedChange diff --git a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx b/src/components/pages/vaults/components/list/VaultsListRow.test.tsx index aa0ab9b44..f215458d8 100644 --- a/src/components/pages/vaults/components/list/VaultsListRow.test.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRow.test.tsx @@ -1,35 +1,89 @@ -// @vitest-environment jsdom - import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import { fireEvent, render } from '@testing-library/react' -import { act } from 'react' +import { YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderToStaticMarkup } from 'react-dom/server' import { MemoryRouter } from 'react-router' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { VaultsListRow } from './VaultsListRow' -vi.mock('@vaults/components/table/VaultForwardAPY', () => ({ +const { mockUseMediaQuery, mockUseYvUsdVaults } = vi.hoisted(() => ({ + mockUseMediaQuery: vi.fn(() => false), + mockUseYvUsdVaults: vi.fn((): any => ({ + metrics: undefined, + unlockedVault: undefined, + lockedVault: undefined + })) +})) + +vi.mock('@react-hookz/web', () => ({ + useMediaQuery: mockUseMediaQuery +})) + +vi.mock('@shared/contexts/useWallet', () => ({ + useWallet: () => ({ + getBalance: () => ({ raw: 0n, normalized: 0 }), + getToken: () => ({ value: 0 }) + }) +})) + +vi.mock('@shared/contexts/useWeb3', () => ({ + useWeb3: () => ({ + address: undefined + }) +})) + +vi.mock('@hooks/usePlausible', () => ({ + usePlausible: () => vi.fn() +})) + +vi.mock('@pages/vaults/hooks/useYvUsdVaults', () => ({ + useYvUsdVaults: mockUseYvUsdVaults +})) + +vi.mock('@pages/vaults/components/table/VaultForwardAPY', () => ({ VaultForwardAPY: () =>
{'APY'}
, VaultForwardAPYInlineDetails: () =>
{'APY details'}
})) -vi.mock('@vaults/components/table/VaultHistoricalAPY', () => ({ +vi.mock('@pages/vaults/components/table/VaultHistoricalAPY', () => ({ VaultHistoricalAPY: () =>
{'Historical APY'}
})) -vi.mock('@vaults/components/table/VaultHoldingsAmount', () => ({ +vi.mock('@pages/vaults/components/table/VaultHoldingsAmount', () => ({ VaultHoldingsAmount: () =>
{'Holdings'}
})) -vi.mock('@vaults/components/table/VaultRiskScoreTag', () => ({ +vi.mock('@pages/vaults/components/table/VaultRiskScoreTag', () => ({ VaultRiskScoreTag: () =>
{'Risk'}
, RiskScoreInlineDetails: () =>
{'Risk details'}
})) +function renderRowHtml(vault: TKongVaultInput): string { + const queryClient = new QueryClient() + + return renderToStaticMarkup( + + + + + + ) +} + describe('VaultsListRow', () => { - it('shows TVL native units tooltip when hovering the value', () => { - vi.useFakeTimers() + beforeEach(() => { + mockUseMediaQuery.mockReturnValue(false) + mockUseYvUsdVaults.mockReturnValue({ + metrics: undefined, + unlockedVault: undefined, + lockedVault: undefined + }) + }) + + it('renders the desktop TVL tooltip trigger for standard vault rows', () => { const vault = { + version: '3.0.0', chainID: 1, address: '0x0000000000000000000000000000000000000001', name: 'Test Vault', @@ -49,23 +103,283 @@ describe('VaultsListRow', () => { } } as unknown as TKongVaultInput - const { container, queryByText } = render( - - - - ) + const html = renderRowHtml(vault) - const trigger = container.querySelector('.tvl-subline-tooltip') + expect(html).toContain('tvl-subline-tooltip') + }) - expect(trigger).not.toBeNull() - expect(queryByText('TKN')).toBeNull() + it('stacks the yvUSD mobile up-to label above the APY value', () => { + mockUseMediaQuery.mockReturnValue(true) + mockUseYvUsdVaults.mockReturnValue({ + metrics: { + unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, + locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } + }, + unlockedVault: undefined, + lockedVault: undefined + }) - fireEvent.mouseEnter(trigger as Element) - act(() => { - vi.advanceTimersByTime(150) + const vault = { + version: '3.0.0', + chainID: 1, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + symbol: 'yvUSD', + category: 'Stablecoin', + kind: 'Multi Strategy', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + decimals: 6 + }, + apr: { + forwardAPR: { + netAPR: 0.05 + }, + netAPR: 0.05 + }, + tvl: { + tvl: 350, + totalAssets: 350_000_000 + }, + info: { + riskLevel: 2 + }, + staking: { + address: '0x0000000000000000000000000000000000000000' + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).toContain('Up to') + expect(html).toContain('9.00%') + expect(html).toContain('inline-flex flex-col items-start') + }) + + it('formats yvUSD locked APY with shared significant-digit rounding in the list row', () => { + mockUseMediaQuery.mockReturnValue(true) + mockUseYvUsdVaults.mockReturnValue({ + metrics: { + unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, + locked: { apy: 1.1777, tvl: 250, hasInfinifiPoints: false } + }, + unlockedVault: undefined, + lockedVault: undefined + }) + + const vault = { + version: '3.0.0', + chainID: 1, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + symbol: 'yvUSD', + category: 'Stablecoin', + kind: 'Multi Strategy', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + decimals: 6 + }, + apr: { + forwardAPR: { + netAPR: 0.05 + }, + netAPR: 0.05 + }, + tvl: { + tvl: 350, + totalAssets: 350_000_000 + }, + info: { + riskLevel: 2 + }, + staking: { + address: '0x0000000000000000000000000000000000000000' + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).toContain('118%') + expect(html).not.toContain('117.77%') + expect(html).toContain('flex items-center justify-center gap-2 whitespace-nowrap') + }) + + it('positions the desktop yvUSD up-to label above the APY value without changing row flow', () => { + mockUseMediaQuery.mockReturnValue(false) + mockUseYvUsdVaults.mockReturnValue({ + metrics: { + unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, + locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } + }, + unlockedVault: undefined, + lockedVault: undefined }) - expect(queryByText('TKN')).not.toBeNull() - vi.useRealTimers() + const vault = { + version: '3.0.0', + chainID: 1, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + symbol: 'yvUSD', + category: 'Stablecoin', + kind: 'Multi Strategy', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + decimals: 6 + }, + apr: { + forwardAPR: { + netAPR: 0.05 + }, + netAPR: 0.05 + }, + tvl: { + tvl: 350, + totalAssets: 350_000_000 + }, + info: { + riskLevel: 2 + }, + staking: { + address: '0x0000000000000000000000000000000000000000' + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).toContain('inline-flex items-center gap-2 text-right') + expect(html).toContain('relative inline-flex') + expect(html).toContain('absolute bottom-full left-0 mb-0.5') + }) + + it('shows the Infinifi points icon for yvUSD when either variant has points', () => { + mockUseYvUsdVaults.mockReturnValue({ + metrics: { + unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, + locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: true } + }, + unlockedVault: undefined, + lockedVault: undefined + }) + + const vault = { + version: '3.0.0', + chainID: 1, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + symbol: 'yvUSD', + category: 'Stablecoin', + kind: 'Multi Strategy', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + decimals: 6 + }, + apr: { + forwardAPR: { + netAPR: 0.05 + }, + netAPR: 0.05 + }, + tvl: { + tvl: 350, + totalAssets: 350_000_000 + }, + info: { + riskLevel: 2 + }, + staking: { + address: '0x0000000000000000000000000000000000000000' + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).toContain('aria-label="Infinifi points"') + }) + + it('does not show the Infinifi points icon for yvUSD without points', () => { + mockUseYvUsdVaults.mockReturnValue({ + metrics: { + unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: false }, + locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: false } + }, + unlockedVault: undefined, + lockedVault: undefined + }) + + const vault = { + version: '3.0.0', + chainID: 1, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + symbol: 'yvUSD', + category: 'Stablecoin', + kind: 'Multi Strategy', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'USDC', + decimals: 6 + }, + apr: { + forwardAPR: { + netAPR: 0.05 + }, + netAPR: 0.05 + }, + tvl: { + tvl: 350, + totalAssets: 350_000_000 + }, + info: { + riskLevel: 2 + }, + staking: { + address: '0x0000000000000000000000000000000000000000' + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).not.toContain('aria-label="Infinifi points"') + }) + + it('does not show the Infinifi points icon for non-yvUSD rows', () => { + mockUseYvUsdVaults.mockReturnValue({ + metrics: { + unlocked: { apy: 0.05, tvl: 100, hasInfinifiPoints: true }, + locked: { apy: 0.09, tvl: 250, hasInfinifiPoints: true } + }, + unlockedVault: undefined, + lockedVault: undefined + }) + + const vault = { + version: '3.0.0', + chainID: 1, + address: '0x0000000000000000000000000000000000000001', + name: 'Test Vault', + category: 'Test Category', + kind: 'Multi Strategy', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'TKN', + decimals: 6 + }, + tvl: { + tvl: 1234, + totalAssets: 1234567 + }, + info: { + riskLevel: 3 + } + } as unknown as TKongVaultInput + + const html = renderRowHtml(vault) + + expect(html).not.toContain('aria-label="Infinifi points"') }) }) diff --git a/src/components/pages/vaults/components/list/VaultsListRow.tsx b/src/components/pages/vaults/components/list/VaultsListRow.tsx index b08f6ba11..ed3b45e35 100755 --- a/src/components/pages/vaults/components/list/VaultsListRow.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRow.tsx @@ -1,8 +1,14 @@ import Link from '@components/Link' import { usePlausible } from '@hooks/usePlausible' +import { APYDetailsModal } from '@pages/vaults/components/table/APYDetailsModal' import { type TVaultForwardAPYVariant, VaultForwardAPY } from '@pages/vaults/components/table/VaultForwardAPY' import { VaultHoldingsAmount } from '@pages/vaults/components/table/VaultHoldingsAmount' import { VaultTVL } from '@pages/vaults/components/table/VaultTVL' +import { + YvUsdApyDetailsContent, + YvUsdApyTooltipContent, + YvUsdTvlTooltipContent +} from '@pages/vaults/components/yvUSD/YvUsdBreakdown' import { getVaultAddress, getVaultAPR, @@ -10,13 +16,15 @@ import { getVaultChainID, getVaultName as getVaultDisplayName, getVaultKind, - getVaultStaking, getVaultSymbol, getVaultToken, + getVaultTVL, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest' import { deriveListKind } from '@pages/vaults/utils/vaultListFacets' +import { getVaultPrimaryLogoSrc } from '@pages/vaults/utils/vaultLogo' import { getCategoryDescription, getChainDescription, @@ -24,21 +32,33 @@ import { getProductTypeDescription, HIDDEN_TAG_DESCRIPTION, MIGRATABLE_TAG_DESCRIPTION, + NOT_YEARN_TAG_DESCRIPTION, RETIRED_TAG_DESCRIPTION } from '@pages/vaults/utils/vaultTagCopy' +import { + getYvUsdInfinifiPointsNote, + getYvUsdSharePrice, + isYvUsdAddress, + YVUSD_CHAIN_ID, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' import { useMediaQuery } from '@react-hookz/web' +import { RenderAmount } from '@shared/components/RenderAmount' import { TokenLogo } from '@shared/components/TokenLogo' +import { Tooltip } from '@shared/components/Tooltip' import { useWallet } from '@shared/contexts/useWallet' 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 { IconInfinifiPoints } from '@shared/icons/IconInfinifiPoints' +import { cl, formatAmount, formatApyDisplay, 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' import { useQueryClient } from '@tanstack/react-query' -import type { ReactElement } from 'react' +import type { MouseEvent, ReactElement } from 'react' import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router' import type { TVaultsExpandedView } from './VaultsExpandedSelector' @@ -46,27 +66,147 @@ import { VaultsListChip } from './VaultsListChip' const VaultsListRowExpandedContent = lazy(() => import('./VaultsListRowExpandedContent')) -const ExpandedRowFallback = (): ReactElement => ( -
-
-
- +function ExpandedRowFallback(): ReactElement { + return ( +
+
+
+ +
-
-) + ) +} type TVaultRowFlags = { hasHoldings?: boolean isMigratable?: boolean isRetired?: boolean isHidden?: boolean + isNotYearn?: boolean } +type TVaultKindType = 'multi' | 'single' | undefined +type TVaultProductType = 'v3' | 'lp' +type TVaultProductTypePresentation = { + productType: TVaultProductType + label: string + ariaLabel: string + isLegacy: boolean +} + +type TYvUsdListMetrics = { + unlockedApy: number + lockedApy: number + unlockedTvl: number + lockedTvl: number + combinedTvl: number + hasInfinifiPointsNote: boolean +} + +const YVUSD_HOLDINGS_FORMAT_OPTIONS = { + minimumFractionDigits: 2, + maximumFractionDigits: 2 +} as const + const prefetchedSnapshotEndpoints = new Set() -const buildSnapshotEndpoint = (chainId: number, address: string): string => - `${KONG_REST_BASE}/snapshot/${chainId}/${toAddress(address)}` +function buildSnapshotEndpoint(chainId: number, address: string): string { + return `${KONG_REST_BASE}/snapshot/${chainId}/${toAddress(address)}` +} + +function getVaultProductTypePresentation(listKind: ReturnType): TVaultProductTypePresentation { + if (listKind === 'allocator' || listKind === 'strategy') { + return { + productType: 'v3', + label: 'Single Asset', + ariaLabel: 'Show single asset vaults', + isLegacy: false + } + } + + if (listKind === 'legacy') { + return { + productType: 'lp', + label: 'Legacy', + ariaLabel: 'Legacy vault', + isLegacy: true + } + } + + return { + productType: 'lp', + label: 'LP Token', + ariaLabel: 'Show LP token vaults', + isLegacy: false + } +} + +function getVaultKindType( + kind: string | null | undefined, + listKind: ReturnType +): TVaultKindType { + if (kind === 'Multi Strategy') { + return 'multi' + } + + if (kind === 'Single Strategy') { + return 'single' + } + + if (listKind === 'allocator') { + return 'multi' + } + + if (listKind === 'strategy') { + return 'single' + } + + return undefined +} + +function getVaultKindLabel(kindType: TVaultKindType, fallbackKind: string | null | undefined): string | undefined { + if (kindType === 'multi') { + return 'Allocator' + } + + if (kindType === 'single') { + return 'Strategy' + } + + return fallbackKind ?? undefined +} + +function getYvUsdListMetrics({ + currentVault, + apr, + isYvUsd, + yvUsdMetrics +}: { + currentVault: TKongVaultInput + apr: ReturnType + isYvUsd: boolean + yvUsdMetrics: ReturnType['metrics'] +}): TYvUsdListMetrics | null { + if (!isYvUsd) { + return null + } + + const vaultTvl = getVaultTVL(currentVault) + const unlockedApy = yvUsdMetrics?.unlocked.apy ?? (apr?.forwardAPR?.netAPR || apr?.netAPR || 0) + const unlockedTvl = yvUsdMetrics?.unlocked.tvl ?? vaultTvl.tvl ?? 0 + const lockedApy = yvUsdMetrics?.locked.apy ?? 0 + const lockedTvl = yvUsdMetrics?.locked.tvl ?? 0 + + return { + unlockedApy, + lockedApy, + unlockedTvl, + lockedTvl, + combinedTvl: vaultTvl.tvl ?? unlockedTvl + lockedTvl, + hasInfinifiPointsNote: Boolean(yvUsdMetrics?.locked.hasInfinifiPoints || yvUsdMetrics?.unlocked.hasInfinifiPoints) + } +} export function VaultsListRow({ currentVault, @@ -122,36 +262,31 @@ 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) const href = hrefOverride ?? `/vaults/${chainID}/${toAddress(vaultAddress)}` const network = getNetwork(chainID) const chainLogoSrc = `${import.meta.env.VITE_BASE_YEARN_ASSETS_URI}/chains/${chainID}/logo-32.png` + const isYvUsd = isYvUsdAddress(vaultAddress) + const tokenLogoSrc = getVaultPrimaryLogoSrc(currentVault) const { address } = useWeb3() - const { getToken } = useWallet() + const { getVaultHoldingsUsd } = useWallet() + const { getBalance } = useWallet() const isMobile = useMediaQuery('(max-width: 767px)', { initializeWithValue: false }) ?? false const [isExpandedState, setIsExpandedState] = useState(false) const isExpanded = isExpandedProp ?? isExpandedState const [expandedView, setExpandedView] = useState('strategies') const [interactiveHoverCount, setInteractiveHoverCount] = useState(0) + const [isYvUsdModalOpen, setIsYvUsdModalOpen] = useState(false) const queryClient = useQueryClient() const listKind = deriveListKind(currentVault) - const isAllocatorVault = listKind === 'allocator' || listKind === 'strategy' - const isLegacyVault = listKind === 'legacy' - const productType = isAllocatorVault ? 'v3' : 'lp' - const productTypeLabel = (() => { - if (isAllocatorVault) return 'Single Asset' - if (isLegacyVault) return 'Legacy' - return 'LP Token' - })() - - const productTypeAriaLabel = (() => { - if (isAllocatorVault) return 'Show single asset vaults' - if (isLegacyVault) return 'Legacy vault' - return 'Show LP token vaults' - })() + const { + productType, + label: productTypeLabel, + ariaLabel: productTypeAriaLabel, + isLegacy: isLegacyVault + } = getVaultProductTypePresentation(listKind) const showProductTypeChip = showProductTypeChipOverride ?? (Boolean(activeProductType) || Boolean(onToggleVaultType)) const isProductTypeActive = activeProductType === productType const shouldCollapseProductTypeChip = @@ -173,6 +308,41 @@ export function VaultsListRow({ const handleInteractiveHoverChange = (isHovering: boolean): void => { setInteractiveHoverCount((count) => Math.max(0, count + (isHovering ? 1 : -1))) } + const { metrics: yvUsdMetrics, unlockedVault: yvUsdUnlockedVault, lockedVault: yvUsdLockedVault } = useYvUsdVaults() + const resolvedYvUsdMetrics = useMemo( + () => getYvUsdListMetrics({ currentVault, apr, isYvUsd, yvUsdMetrics }), + [apr, currentVault, isYvUsd, yvUsdMetrics] + ) + + const yvUsdApyTooltip = resolvedYvUsdMetrics ? ( + + ) : undefined + + const yvUsdApyValue = resolvedYvUsdMetrics ? ( + <> + {resolvedYvUsdMetrics.hasInfinifiPointsNote ? ( + + ) : null} + {formatApyDisplay(resolvedYvUsdMetrics.lockedApy)} + + ) : null + + const yvUsdTvlTooltip = resolvedYvUsdMetrics ? ( + + ) : undefined + + const handleYvUsdApyClick = (event: MouseEvent): void => { + event.stopPropagation() + event.preventDefault() + setIsYvUsdModalOpen(true) + } const handleExpandedChange = (next: boolean): void => { if (onExpandedChange) { onExpandedChange(next) @@ -201,24 +371,8 @@ export function VaultsListRow({ }, [vaultAddress, chainID, queryClient]) const isHiddenVault = Boolean(flags?.isHidden) - const baseKindType: 'multi' | 'single' | undefined = (() => { - if (vaultKind === 'Multi Strategy') return 'multi' - if (vaultKind === 'Single Strategy') return 'single' - return undefined - })() - - const fallbackKindType: 'multi' | 'single' | undefined = (() => { - if (listKind === 'allocator') return 'multi' - if (listKind === 'strategy') return 'single' - return undefined - })() - - const kindType = baseKindType ?? fallbackKindType - const kindLabel: string | undefined = (() => { - if (kindType === 'multi') return 'Allocator' - if (kindType === 'single') return 'Strategy' - return vaultKind - })() + const kindType = getVaultKindType(vaultKind, listKind) + const kindLabel = getVaultKindLabel(kindType, vaultKind) const activeChainIds = activeChains ?? [] const activeCategoryLabels = activeCategories ?? [] const showKindChip = showStrategies && Boolean(kindType) && (showAllocatorChip || kindType !== 'multi') @@ -259,25 +413,29 @@ export function VaultsListRow({ const hasHoldings = Boolean(flags?.hasHoldings) const showHoldingsChip = showHoldingsChipOverride ?? hasHoldings const showHoldingsValue = hasHoldings + const holdingsFormatOptions = isYvUsd ? YVUSD_HOLDINGS_FORMAT_OPTIONS : undefined const holdingsValue = useMemo(() => { 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]) + if (isYvUsd) { + const unlockedBalance = getBalance({ address: YVUSD_UNLOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }).normalized + const lockedBalance = getBalance({ address: YVUSD_LOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }).normalized + const unlockedSharePrice = getYvUsdSharePrice(yvUsdUnlockedVault) + const lockedSharePrice = getYvUsdSharePrice(yvUsdLockedVault) + return unlockedBalance * unlockedSharePrice + lockedBalance * lockedSharePrice + } + return getVaultHoldingsUsd(currentVault) + }, [ + showHoldingsChip, + mobileSecondaryMetric, + isYvUsd, + getBalance, + yvUsdLockedVault, + yvUsdUnlockedVault, + currentVault, + getVaultHoldingsUsd + ]) useEffect(() => { if (isExpanded) { @@ -288,12 +446,12 @@ export function VaultsListRow({ return (
+ + ) : ( + + )}
-
+
{mobileSecondaryMetric === 'holdings' ? 'Holdings:' : 'TVL:'} {mobileSecondaryMetric === 'holdings' ? ( - {showHoldingsValue ? formatTvlDisplay(holdingsValue) : '—'} + {showHoldingsValue ? formatTvlDisplay(holdingsValue, holdingsFormatOptions) : '—'} + ) : resolvedYvUsdMetrics ? ( + + + + + ) : ( )} @@ -575,25 +802,92 @@ export function VaultsListRow({ className={cl(rightColumnSpan, 'z-10 gap-4 mt-4', 'hidden md:mt-0 md:grid md:items-center', rightGridColumns)} >
- + {resolvedYvUsdMetrics ? ( +
handleInteractiveHoverChange(true)} + onMouseLeave={() => handleInteractiveHoverChange(false)} + > + + + +
+ ) : ( + + )}
{/* TVL */}
-
- -
+ {resolvedYvUsdMetrics ? ( +
handleInteractiveHoverChange(true)} + onMouseLeave={() => handleInteractiveHoverChange(false)} + > + +

+ +

+
+
+ ) : ( +
+ +
+ )}
{!showHoldingsColumn ?
: null} {showHoldingsColumn ? (
- +
) : null}
@@ -612,6 +906,14 @@ export function VaultsListRow({ /> ) : null} + {isYvUsd && resolvedYvUsdMetrics ? ( + setIsYvUsdModalOpen(false)} title={'yvUSD APY'}> + + + ) : null}
) } diff --git a/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx b/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx index a93ee9448..af287bbe7 100644 --- a/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx +++ b/src/components/pages/vaults/components/list/VaultsListRowExpandedContent.tsx @@ -5,6 +5,7 @@ import { type TVaultChartTimeframe, VaultChartsSection } from '@pages/vaults/components/detail/VaultChartsSection' +import { YvUsdChartsSection } from '@pages/vaults/components/detail/YvUsdChartsSection' import { getVaultAddress, getVaultChainID, @@ -18,6 +19,7 @@ import { type TKongVaultStrategy } from '@pages/vaults/domain/kongVaultSelectors' import { useVaultSnapshot } from '@pages/vaults/hooks/useVaultSnapshot' +import { isYvUsdAddress } from '@pages/vaults/utils/yvUsd' import { AllocationChart, DARK_MODE_COLORS, @@ -30,7 +32,7 @@ import { useYearnTokenPrice } from '@shared/hooks/useYearnTokenPrice' import { formatCounterValue, toAddress, toBigInt, toNormalizedBN } from '@shared/utils' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' -import type { ReactElement } from 'react' +import type { MouseEvent, ReactElement } from 'react' import { useMemo } from 'react' import { type TVaultsExpandedView, VaultsExpandedSelector } from './VaultsExpandedSelector' @@ -43,6 +45,13 @@ const EXPANDED_VIEW_TO_CHART_TAB: Record< tvl: 'historical-tvl' } +type TExpandedChartView = keyof typeof EXPANDED_VIEW_TO_CHART_TAB +type TMergedStrategy = TKongVaultStrategy & { name: string } + +function isExpandedChartView(view: TVaultsExpandedView): view is TExpandedChartView { + return view in EXPANDED_VIEW_TO_CHART_TAB +} + type TVaultsListRowExpandedContentProps = { currentVault: TKongVaultInput expandedView: TVaultsExpandedView @@ -66,13 +75,15 @@ export default function VaultsListRowExpandedContent({ const chartTimeframe: TVaultChartTimeframe = '1y' const chainID = getVaultChainID(currentVault) const vaultAddress = getVaultAddress(currentVault) + const isYvUsd = isYvUsdAddress(vaultAddress) const { data: snapshotVault } = useVaultSnapshot({ chainId: chainID, address: vaultAddress }) const snapshotMergedVault = useMemo(() => getVaultView(currentVault, snapshotVault), [currentVault, snapshotVault]) + const chartTab = isExpandedChartView(expandedView) ? EXPANDED_VIEW_TO_CHART_TAB[expandedView] : undefined - const handleGoToVault = (event: React.MouseEvent): void => { + const handleGoToVault = (event: MouseEvent): void => { event.stopPropagation() trackEvent(PLAUSIBLE_EVENTS.VAULT_CLICK_LIST_ROW_EXPANDED, { props: { @@ -114,16 +125,26 @@ export default function VaultsListRowExpandedContent({ } /> - {expandedView in EXPANDED_VIEW_TO_CHART_TAB ? ( - + {chartTab ? ( + isYvUsd ? ( + + ) : ( + + ) ) : ( )} @@ -150,18 +171,17 @@ function VaultStrategyAllocationPreview({ }) const isDark = useDarkMode() - type TMergedStrategy = TKongVaultStrategy & { name: string } - const mergedList = useMemo(() => { - const list: TMergedStrategy[] = [] - for (const strategy of strategies) { - const linkedVault = vaults[toAddress(strategy.address)] - list.push({ - ...strategy, - name: strategy.name || (linkedVault ? getVaultName(linkedVault) : `Strategy ${list.length + 1}`) - }) - } - return list - }, [strategies, vaults]) + const mergedList = useMemo( + () => + strategies.map((strategy, index): TMergedStrategy => { + const linkedVault = vaults[toAddress(strategy.address)] + return { + ...strategy, + name: strategy.name || (linkedVault ? getVaultName(linkedVault) : `Strategy ${index + 1}`) + } + }), + [strategies, vaults] + ) const filteredVaultList = useMemo( () => mergedList.filter((strategy) => strategy.status !== 'not_active'), diff --git a/src/components/pages/vaults/components/notifications/Notification.tsx b/src/components/pages/vaults/components/notifications/Notification.tsx index 3f768ff6f..0d59d2cc0 100644 --- a/src/components/pages/vaults/components/notifications/Notification.tsx +++ b/src/components/pages/vaults/components/notifications/Notification.tsx @@ -119,6 +119,64 @@ function ApproveNotificationContent({ notification }: { notification: TNotificat ) } +function CooldownNotificationContent({ notification }: { notification: TNotification }): ReactElement { + const chainName = NETWORK_BY_CHAIN_ID.get(notification.chainId)?.name || 'Unknown' + + const explorerBaseURI = useMemo(() => { + const chain = NETWORK_BY_CHAIN_ID.get(notification.chainId) + return chain?.blockExplorers?.default?.url || 'https://etherscan.io' + }, [notification.chainId]) + + return ( +
+
+ +
+
+
+

{'Address:'}

+

+ + + +

+

{'Locked shares:'}

+

+ + + +

+

{'Chain:'}

+

{chainName}

+
+
+
+ ) +} + function DepositNotificationContent({ notification }: { notification: TNotification }): ReactElement { const fromChainName = NETWORK_BY_CHAIN_ID.get(notification.chainId)?.name || 'Unknown' const toChainName = notification.toChainId @@ -441,6 +499,10 @@ function NotificationContent({ notification }: { notification: TNotification }): return } + if (['start cooldown', 'cancel cooldown'].includes(notification.type)) { + return + } + if (['deposit', 'stake', 'zap', 'crosschain zap', 'deposit and stake'].includes(notification.type)) { return } @@ -500,6 +562,10 @@ export const Notification = memo(function Notification({ return 'Deposit' case 'withdraw': return 'Withdraw' + case 'start cooldown': + return 'Start Cooldown' + case 'cancel cooldown': + return 'Cancel Cooldown' case 'zap': return 'Zap' case 'crosschain zap': diff --git a/src/components/pages/vaults/components/table/VaultHoldingsAmount.tsx b/src/components/pages/vaults/components/table/VaultHoldingsAmount.tsx index e3972d750..3c8ee6992 100644 --- a/src/components/pages/vaults/components/table/VaultHoldingsAmount.tsx +++ b/src/components/pages/vaults/components/table/VaultHoldingsAmount.tsx @@ -4,10 +4,15 @@ import type { ReactElement } from 'react' export function VaultHoldingsAmount({ value, - valueClassName + valueClassName, + formatOptions }: { value: number valueClassName?: string + formatOptions?: { + minimumFractionDigits?: number + maximumFractionDigits?: number + } }): ReactElement { const { address } = useWeb3() const isWalletActive = !!address @@ -24,7 +29,7 @@ export function VaultHoldingsAmount({ valueClassName )} > - {shouldShowDash ? '-' : formatTvlDisplay(isDusty ? 0 : value)} + {shouldShowDash ? '-' : formatTvlDisplay(isDusty ? 0 : value, formatOptions)}

) diff --git a/src/components/pages/vaults/components/widget/InputTokenAmount.tsx b/src/components/pages/vaults/components/widget/InputTokenAmount.tsx index 789e79505..efda02d6b 100644 --- a/src/components/pages/vaults/components/widget/InputTokenAmount.tsx +++ b/src/components/pages/vaults/components/widget/InputTokenAmount.tsx @@ -78,7 +78,7 @@ export const InputTokenAmount: FC = ({ const handleInputChange = (event: ChangeEvent) => { handleChangeInput(event) - onInputChange?.(simpleToExact(event.target.value)) + onInputChange?.(simpleToExact(event.target.value, decimals ?? input[0].decimals)) } const handleTokenButtonClick = () => { @@ -122,7 +122,7 @@ export const InputTokenAmount: FC = ({ } return ( -
+
{/* Top row - Title and percentage buttons */}
diff --git a/src/components/pages/vaults/components/widget/TokenSelector.tsx b/src/components/pages/vaults/components/widget/TokenSelector.tsx index b469eb9c6..8b29f78b2 100644 --- a/src/components/pages/vaults/components/widget/TokenSelector.tsx +++ b/src/components/pages/vaults/components/widget/TokenSelector.tsx @@ -16,6 +16,7 @@ interface TokenSelectorProps { limitTokens?: `0x${string}`[] excludeTokens?: `0x${string}`[] priorityTokens?: Record // chainId -> addresses to always show + extraTokens?: TToken[] onClose?: () => void assetAddress?: `0x${string}` vaultAddress?: `0x${string}` @@ -85,6 +86,7 @@ export const TokenSelector: FC = ({ limitTokens, excludeTokens, priorityTokens, + extraTokens, onClose, assetAddress, vaultAddress, @@ -155,8 +157,16 @@ export const TokenSelector: FC = ({ } } + // Include explicit extra tokens (used by custom widget flows) + const chainExtraTokens = (extraTokens || []).filter((token) => token.chainID === selectedChainId) + for (const extraToken of chainExtraTokens) { + if (!tokenList.some((t) => t.address?.toLowerCase() === extraToken.address?.toLowerCase())) { + tokenList.push(extraToken) + } + } + return tokenList - }, [balances, selectedChainId, value, customAddress, getToken, priorityTokens]) + }, [balances, selectedChainId, value, customAddress, getToken, priorityTokens, extraTokens]) // Filter tokens based on search and limits const filteredTokens = useMemo(() => { diff --git a/src/components/pages/vaults/components/widget/WalletPanel.tsx b/src/components/pages/vaults/components/widget/WalletPanel.tsx index f093767b3..0bd6a457e 100644 --- a/src/components/pages/vaults/components/widget/WalletPanel.tsx +++ b/src/components/pages/vaults/components/widget/WalletPanel.tsx @@ -4,17 +4,23 @@ import { getVaultTVL, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' -import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' -import { TokenLogo } from '@shared/components/TokenLogo' +import { useVaultUserData, type VaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' +import { + getYvUsdSharePrice, + isYvUsdVault, + YVUSD_CHAIN_ID, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' import { useNotifications } from '@shared/contexts/useNotifications' -import { useWallet } from '@shared/contexts/useWallet' import { useWeb3 } from '@shared/contexts/useWeb3' import { useYearn } from '@shared/contexts/useYearn' +import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' import { IconCheck } from '@shared/icons/IconCheck' import { IconCross } from '@shared/icons/IconCross' import { IconLoader } from '@shared/icons/IconLoader' import { IconWallet } from '@shared/icons/IconWallet' -import type { TToken } from '@shared/types' import type { TNotification, TNotificationStatus } from '@shared/types/notifications' import { cl, @@ -25,10 +31,11 @@ import { toNormalizedBN, truncateHex } from '@shared/utils' -import { getVaultName } from '@shared/utils/helpers' import { getNetwork } from '@shared/utils/wagmi/utils' -import { type FC, type ReactElement, useCallback, useMemo, useState } from 'react' +import { type FC, type ReactElement, useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router' +import { useReadContract } from 'wagmi' +import { formatDuration, parseCooldownStatus } from './yvUSD/cooldownUtils' type WalletPanelProps = { isActive: boolean @@ -37,7 +44,6 @@ type WalletPanelProps = { stakingAddress?: `0x${string}` chainId: number vaultUserData: VaultUserData - onSelectZapToken?: (token: TToken) => void } const WALLET_TABS = [ @@ -58,23 +64,168 @@ const STATUS_STYLES: Record } } +function YvUsdVaultBalances({ account }: { account?: `0x${string}` }): ReactElement { + const { getPrice } = useYearn() + const { unlockedVault, lockedVault, isLoading: isLoadingYvUsd } = useYvUsdVaults() + const [nowTimestamp, setNowTimestamp] = useState(() => Math.floor(Date.now() / 1000)) + + const unlockedAssetAddress = toAddress(unlockedVault?.token.address ?? YVUSD_UNLOCKED_ADDRESS) + const unlockedUserData = useVaultUserData({ + vaultAddress: toAddress(unlockedVault?.address ?? YVUSD_UNLOCKED_ADDRESS), + assetAddress: unlockedAssetAddress, + chainId: YVUSD_CHAIN_ID, + account + }) + const lockedUserData = useVaultUserData({ + vaultAddress: toAddress(lockedVault?.address ?? YVUSD_LOCKED_ADDRESS), + assetAddress: YVUSD_UNLOCKED_ADDRESS, + chainId: YVUSD_CHAIN_ID, + account + }) + const { data: rawCooldownStatus, isLoading: isLoadingCooldownStatus } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'getCooldownStatus', + args: account ? [toAddress(account)] : undefined, + chainId: YVUSD_CHAIN_ID, + query: { + enabled: !!account, + refetchInterval: account ? 30_000 : false + } + }) + const { data: rawAvailableWithdrawLimit, isLoading: isLoadingAvailableWithdrawLimit } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'availableWithdrawLimit', + args: account ? [toAddress(account)] : undefined, + chainId: YVUSD_CHAIN_ID, + query: { + enabled: !!account, + refetchInterval: account ? 30_000 : false + } + }) + const cooldownStatus = useMemo(() => parseCooldownStatus(rawCooldownStatus), [rawCooldownStatus]) + const hasActiveCooldown = cooldownStatus.shares > 0n + const isCooldownActive = hasActiveCooldown && nowTimestamp < cooldownStatus.cooldownEnd + const isWithdrawalWindowOpen = + hasActiveCooldown && nowTimestamp >= cooldownStatus.cooldownEnd && nowTimestamp <= cooldownStatus.windowEnd + const cooldownRemainingSeconds = isCooldownActive ? cooldownStatus.cooldownEnd - nowTimestamp : 0 + const windowRemainingSeconds = isWithdrawalWindowOpen ? cooldownStatus.windowEnd - nowTimestamp : 0 + const availableWithdrawLimit = typeof rawAvailableWithdrawLimit === 'bigint' ? rawAvailableWithdrawLimit : 0n + const sharesUnderCooldown = hasActiveCooldown ? cooldownStatus.shares : 0n + const assetsUnderCooldown = useMemo(() => { + if (!hasActiveCooldown || lockedUserData.pricePerShare <= 0n) return 0n + const vaultDecimals = lockedUserData.vaultToken?.decimals ?? 18 + return (sharesUnderCooldown * lockedUserData.pricePerShare) / 10n ** BigInt(vaultDecimals) + }, [hasActiveCooldown, lockedUserData.pricePerShare, lockedUserData.vaultToken?.decimals, sharesUnderCooldown]) + + const unlockedSymbol = unlockedUserData.assetToken?.symbol ?? 'USDC' + const lockedSymbol = lockedUserData.assetToken?.symbol ?? 'yvUSD' + const unlockedDecimals = unlockedUserData.assetToken?.decimals ?? 6 + const lockedDecimals = lockedUserData.assetToken?.decimals ?? 18 + const unlockedAssetPrice = + unlockedUserData.assetToken?.address && unlockedUserData.assetToken?.chainID + ? getPrice({ + address: toAddress(unlockedUserData.assetToken.address), + chainID: unlockedUserData.assetToken.chainID + }).normalized + : unlockedVault?.tvl.price || 0 + const unlockedSharePrice = getYvUsdSharePrice(unlockedVault, unlockedAssetPrice) + const unlockedNormalized = toNormalizedBN(unlockedUserData.depositedValue, unlockedDecimals).normalized + const lockedNormalized = toNormalizedBN(lockedUserData.depositedValue, lockedDecimals).normalized + const unlockedUsd = unlockedNormalized * unlockedAssetPrice + const lockedUsd = lockedNormalized * unlockedSharePrice + const totalUsd = unlockedUsd + lockedUsd + + useEffect(() => { + if (!account) return + setNowTimestamp(Math.floor(Date.now() / 1000)) + const interval = window.setInterval(() => { + setNowTimestamp(Math.floor(Date.now() / 1000)) + }, 1_000) + return () => window.clearInterval(interval) + }, [account]) + + if (isLoadingYvUsd || unlockedUserData.isLoading || lockedUserData.isLoading) { + return

{'Loading yvUSD position data...'}

+ } + + return ( + <> +
+ Deposited value + {formatUSD(totalUsd)} +
+
+ Unlocked position + + {`${formatTAmount({ value: unlockedUserData.depositedValue, decimals: unlockedDecimals })} ${unlockedSymbol}`} + ({formatUSD(unlockedUsd)}) + +
+
+ Locked position + + {`${formatTAmount({ value: lockedUserData.depositedValue, decimals: lockedDecimals })} ${lockedSymbol}`} + ({formatUSD(lockedUsd)}) + +
+ {account && hasActiveCooldown ? ( +
+

{'Cooldown status'}

+ {isLoadingCooldownStatus || isLoadingAvailableWithdrawLimit ? ( +

{'Loading cooldown status...'}

+ ) : ( + <> +

+ {`Shares in cooldown: ${formatTAmount({ + value: sharesUnderCooldown, + decimals: lockedUserData.vaultToken?.decimals ?? 18 + })}`} +

+

+ {`Estimated assets in cooldown: ${formatTAmount({ + value: assetsUnderCooldown, + decimals: lockedUserData.assetToken?.decimals ?? 18 + })} ${lockedUserData.assetToken?.symbol ?? 'USDC'}`} +

+

+ {`Available to withdraw now: ${formatTAmount({ + value: availableWithdrawLimit, + decimals: lockedUserData.assetToken?.decimals ?? 18 + })} ${lockedUserData.assetToken?.symbol ?? 'USDC'}`} +

+

+ {isCooldownActive + ? `Cooldown remaining: ${formatDuration(cooldownRemainingSeconds)}` + : isWithdrawalWindowOpen + ? `Withdrawal window remaining: ${formatDuration(windowRemainingSeconds)}` + : 'Withdrawal window closed. Start a new cooldown to withdraw.'} +

+ + )} +
+ ) : null} + + ) +} + export const WalletPanel: FC = ({ isActive: isPanelActive, currentVault, vaultAddress, stakingAddress, chainId, - vaultUserData, - onSelectZapToken + vaultUserData }) => { const { address, isActive: isWalletActive, openLoginModal } = useWeb3() const { cachedEntries } = useNotifications() const navigate = useNavigate() - const { balances } = useWallet() const { getPrice } = useYearn() const [activeTab, setActiveTab] = useState('balances') const { assetToken, vaultToken, stakingToken, depositedValue, depositedShares, pricePerShare, isLoading } = vaultUserData + const isYvUsd = isYvUsdVault(currentVault) const vaultDecimals = getVaultDecimals(currentVault) const vaultTVL = getVaultTVL(currentVault) @@ -89,7 +240,6 @@ export const WalletPanel: FC = ({ const hasVaultShares = vaultBalance > 0n const hasStakedShares = stakingBalance > 0n const showTotalShares = hasVaultShares && hasStakedShares - const vaultName = getVaultName(currentVault) const assetPrice = assetToken?.address ? getPrice({ address: toAddress(assetToken.address), chainID: assetToken.chainID ?? chainId }).normalized : 0 @@ -155,9 +305,10 @@ export const WalletPanel: FC = ({ const availableUsd = (assetToken?.balance.normalized ?? 0) * assetPrice const relatedAddresses = useMemo(() => { - const addresses = [vaultAddress, stakingAddress].filter(Boolean) as `0x${string}`[] + const yvUsdAddresses = isYvUsd ? [YVUSD_UNLOCKED_ADDRESS, YVUSD_LOCKED_ADDRESS] : [] + const addresses = [...yvUsdAddresses, vaultAddress, stakingAddress].filter(Boolean) as `0x${string}`[] return addresses.map((addr) => toAddress(addr).toLowerCase()) - }, [vaultAddress, stakingAddress]) + }, [isYvUsd, vaultAddress, stakingAddress]) const recentEntries = useMemo(() => { const filtered = ( @@ -173,31 +324,6 @@ export const WalletPanel: FC = ({ return filtered.toReversed().slice(0, 3) }, [address, cachedEntries, relatedAddresses, chainId]) - const zapTokens = useMemo(() => { - const chainBalances = balances[chainId] || {} - const excluded = [vaultAddress, stakingAddress].filter(Boolean).map((addr) => toAddress(addr).toLowerCase()) - const tokens = Object.values(chainBalances).filter((token) => { - if (!token?.address) return false - if (token.balance.raw <= 0n) return false - return !excluded.includes(toAddress(token.address).toLowerCase()) - }) as TToken[] - - const sorted = tokens.toSorted((a, b) => { - const aBalance = a.balance?.raw || 0n - const bBalance = b.balance?.raw || 0n - return bBalance > aBalance ? 1 : -1 - }) - return sorted.map((token) => { - const tokenPrice = getPrice({ address: toAddress(token.address), chainID: token.chainID }).normalized - const tokenUsd = token.balance.normalized * tokenPrice - return { - token, - amountLabel: formatTokenAmount(token.balance.raw, token.decimals, token.symbol, { shouldCompactValue: true }), - usdLabel: formatUSD(tokenUsd) - } - }) - }, [balances, chainId, vaultAddress, stakingAddress, getPrice, formatTokenAmount]) - return (
= ({

Your Vault balances

-
- Deposited value -
- {depositedLabel} - - ({formatUSD(depositedUsd)}) - -
-
-
- - {hasStakedShares && !showTotalShares ? 'Staked shares' : 'Deposited shares'} - - - {hasStakedShares && !showTotalShares ? stakingBalanceLabel : vaultBalanceLabel} - - ({formatUSD(hasStakedShares && !showTotalShares ? stakedSharesUsd : vaultSharesUsd)}) - - -
- {showTotalShares ? ( + {isYvUsd ? ( + + ) : ( <> -
- Staked shares - - {stakingBalanceLabel} - - ({formatUSD(stakedSharesUsd)}) +
+ Deposited value +
+ {depositedLabel} + + ({formatUSD(depositedUsd)}) - +
- Total shares - - {totalSharesLabel} + + {hasStakedShares && !showTotalShares ? 'Staked shares' : 'Deposited shares'} + + + {hasStakedShares && !showTotalShares ? stakingBalanceLabel : vaultBalanceLabel} - ({formatUSD(totalSharesUsd)}) + ({formatUSD(hasStakedShares && !showTotalShares ? stakedSharesUsd : vaultSharesUsd)})
+ {showTotalShares ? ( + <> +
+ Staked shares + + {stakingBalanceLabel} + + ({formatUSD(stakedSharesUsd)}) + + +
+
+ Total shares + + {totalSharesLabel} + + ({formatUSD(totalSharesUsd)}) + + +
+ + ) : null} - ) : null} + )}
@@ -310,46 +442,6 @@ export const WalletPanel: FC = ({
- -
-

Zap-ready tokens

- {zapTokens.length === 0 ? ( -
No wallet tokens available to zap.
- ) : ( -
- {zapTokens.map(({ token, amountLabel, usdLabel }) => ( - - ))} -
- )} -
) : null} diff --git a/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx b/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx index 031177384..4940c8bc3 100644 --- a/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx +++ b/src/components/pages/vaults/components/widget/deposit/DepositDetails.tsx @@ -8,10 +8,12 @@ interface DepositDetailsProps { depositAmountBn: bigint inputTokenSymbol?: string inputTokenDecimals: number + inputTokenUsdPrice: number // Route info routeType: DepositRouteType isSwap: boolean isLoadingQuote: boolean + isQuoteStale: boolean expectedOutInAsset: bigint assetTokenSymbol?: string assetTokenDecimals: number @@ -22,6 +24,7 @@ interface DepositDetailsProps { pricePerShare: bigint assetUsdPrice: number willReceiveStakedShares: boolean + vaultSharesLabel?: string onShowVaultSharesModal: () => void onShowVaultShareValueModal: () => void // Annual return info @@ -40,9 +43,11 @@ export const DepositDetails: FC = ({ depositAmountBn, inputTokenSymbol, inputTokenDecimals, + inputTokenUsdPrice, routeType, isSwap, isLoadingQuote, + isQuoteStale, expectedOutInAsset, assetTokenSymbol, assetTokenDecimals, @@ -52,6 +57,7 @@ export const DepositDetails: FC = ({ pricePerShare, assetUsdPrice, willReceiveStakedShares, + vaultSharesLabel, onShowVaultSharesModal, onShowVaultShareValueModal, estimatedAnnualReturn, @@ -64,7 +70,7 @@ export const DepositDetails: FC = ({ onShowApprovalOverlay }) => { const isStake = routeType === 'DIRECT_STAKE' - const sharesLabel = willReceiveStakedShares ? 'Staked shares' : 'Vault shares' + const sharesLabel = willReceiveStakedShares ? 'Staked shares' : (vaultSharesLabel ?? 'Vault shares') // Determine action verb based on route type const getActionVerb = () => { @@ -80,11 +86,18 @@ export const DepositDetails: FC = ({ ? (expectedVaultShares * pricePerShare) / 10n ** BigInt(vaultDecimals) : 0n const vaultShareValueDisplay = formatWidgetValue(vaultShareValueInAsset, assetTokenDecimals) - const vaultShareValueUsd = formatWidgetValue( - Number(formatUnits(vaultShareValueInAsset, assetTokenDecimals)) * assetUsdPrice - ) + const vaultShareValueUsdRaw = Number(formatUnits(vaultShareValueInAsset, assetTokenDecimals)) * assetUsdPrice + const vaultShareValueUsd = formatWidgetValue(vaultShareValueUsdRaw) + + // Calculate price impact (USD to deposit vs vault share value USD) + const usdValueToDeposit = Number(formatUnits(depositAmountBn, inputTokenDecimals)) * inputTokenUsdPrice + const priceImpact = + usdValueToDeposit > 0 && vaultShareValueUsdRaw > 0 + ? ((usdValueToDeposit - vaultShareValueUsdRaw) / usdValueToDeposit) * 100 + : 0 + const hasHighPriceImpact = !isQuoteStale && !isLoadingQuote && priceImpact > 5 return ( -
+
{/* You will deposit/swap/stake */}
@@ -153,7 +166,7 @@ export const DepositDetails: FC = ({ > Vault share value -

+

{isLoadingQuote ? ( ) : ( @@ -162,6 +175,7 @@ export const DepositDetails: FC = ({ {`${assetTokenSymbol || ''} (`} {`$${vaultShareValueUsd}`} {')'} + {hasHighPriceImpact && {` (-${priceImpact.toFixed(2)}%)`}} )}

diff --git a/src/components/pages/vaults/components/widget/deposit/index.tsx b/src/components/pages/vaults/components/widget/deposit/index.tsx index f43d65341..837128271 100644 --- a/src/components/pages/vaults/components/widget/deposit/index.tsx +++ b/src/components/pages/vaults/components/widget/deposit/index.tsx @@ -10,20 +10,24 @@ 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 { TToken } from '@shared/types' import { cl, formatTAmount, toAddress } from '@shared/utils' import { ETH_TOKEN_ADDRESS } from '@shared/utils/constants' import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible' -import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ReactElement, ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { formatUnits } from 'viem' import { useAccount } from 'wagmi' import { SettingsPanel } from '../SettingsPanel' import { TokenSelectorOverlay } from '../shared/TokenSelectorOverlay' import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' +import { useResetEnsoSelection } from '../shared/useResetEnsoSelection' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import { WidgetHeader } from '../shared/WidgetHeader' import { AnnualReturnOverlay } from './AnnualReturnOverlay' import { ApprovalOverlay } from './ApprovalOverlay' import { DepositDetails } from './DepositDetails' +import type { DepositRouteType } from './types' import { useDepositError } from './useDepositError' import { useDepositFlow } from './useDepositFlow' import { useDepositNotifications } from './useDepositNotifications' @@ -34,6 +38,7 @@ import { VaultShareValueOverlay } from './VaultShareValueOverlay' interface Props { vaultAddress: `0x${string}` assetAddress: `0x${string}` + directDepositTokenAddress?: `0x${string}` stakingAddress?: `0x${string}` chainId: number vaultAPR: number @@ -43,6 +48,8 @@ interface Props { handleDepositSuccess?: () => void onOpenSettings?: () => void isSettingsOpen?: boolean + onAmountChange?: (value: string) => void + onTokenSelectionChange?: (address: `0x${string}`, chainId: number) => void prefill?: { address: `0x${string}` chainId: number @@ -52,11 +59,55 @@ interface Props { hideSettings?: boolean disableBorderRadius?: boolean collapseDetails?: boolean + detailsContent?: ReactNode + contentBelowInput?: ReactNode + vaultSharesLabel?: string + hideDetails?: boolean + hideActionButton?: boolean + hideContainerBorder?: boolean + headerActions?: ReactNode + tokenSelectorExtraTokens?: TToken[] } -export const WidgetDeposit: FC = ({ +type DepositActionCopy = { + actionLabel: string + progressLabel: string + pastTenseLabel: string +} + +function getDepositActionCopy(routeType: DepositRouteType): DepositActionCopy { + if (routeType === 'DIRECT_STAKE') { + return { + actionLabel: 'Stake', + progressLabel: 'Staking', + pastTenseLabel: 'staked' + } + } + + return { + actionLabel: 'Deposit', + progressLabel: 'Depositing', + pastTenseLabel: 'deposited' + } +} + +function getDepositButtonLabel(isLoadingRoute: boolean, needsApproval: boolean, routeType: DepositRouteType): string { + if (isLoadingRoute) { + return 'Fetching quote' + } + + const { actionLabel } = getDepositActionCopy(routeType) + if (needsApproval) { + return `Approve & ${actionLabel}` + } + + return actionLabel +} + +export function WidgetDeposit({ vaultAddress, assetAddress, + directDepositTokenAddress, stakingAddress, chainId, vaultAPR, @@ -66,21 +117,31 @@ export const WidgetDeposit: FC = ({ handleDepositSuccess: onDepositSuccess, onOpenSettings, isSettingsOpen, + onAmountChange, + onTokenSelectionChange, prefill, onPrefillApplied, hideSettings: _hideSettings, disableBorderRadius, - collapseDetails -}) => { + collapseDetails, + detailsContent, + contentBelowInput, + vaultSharesLabel, + hideDetails = false, + hideActionButton = false, + hideContainerBorder = false, + headerActions, + tokenSelectorExtraTokens +}: Props): ReactElement { const { address: account } = useAccount() const { openLoginModal } = useWeb3() const { onRefresh: refreshWalletBalances, getToken } = useWallet() const { zapSlippage, isAutoStakingEnabled, getPrice } = useYearn() const trackEvent = usePlausible() - const ensoEnabled = useEnsoEnabled() + const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) - const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>(assetAddress) - const [selectedChainId, setSelectedChainId] = useState() + const [selectedToken, setSelectedToken] = useState<`0x${string}` | undefined>(prefill?.address ?? assetAddress) + const [selectedChainId, setSelectedChainId] = useState(prefill?.chainId) const [showVaultSharesModal, setShowVaultSharesModal] = useState(false) const [showVaultShareValueModal, setShowVaultShareValueModal] = useState(false) const [showAnnualReturnModal, setShowAnnualReturnModal] = useState(false) @@ -88,6 +149,7 @@ export const WidgetDeposit: FC = ({ const [showTokenSelector, setShowTokenSelector] = useState(false) const [showTransactionOverlay, setShowTransactionOverlay] = useState(false) const [isDetailsPanelOpen, setIsDetailsPanelOpen] = useState(false) + const [hasAcceptedPriceImpact, setHasAcceptedPriceImpact] = useState(false) const appliedPrefillRef = useRef(null) const { @@ -102,13 +164,23 @@ export const WidgetDeposit: FC = ({ const depositToken = selectedToken || assetAddress const sourceChainId = selectedChainId || chainId const isNativeToken = toAddress(depositToken) === toAddress(ETH_TOKEN_ADDRESS) + const selectedExtraToken = useMemo( + () => + tokenSelectorExtraTokens?.find( + (token) => token.chainID === sourceChainId && toAddress(token.address) === toAddress(depositToken) + ), + [tokenSelectorExtraTokens, sourceChainId, depositToken] + ) const inputToken = useMemo(() => { if (sourceChainId === chainId && depositToken === assetAddress) { return assetToken } + if (selectedExtraToken) { + return selectedExtraToken + } return getToken({ address: depositToken, chainID: sourceChainId }) - }, [getToken, depositToken, sourceChainId, chainId, assetAddress, assetToken]) + }, [getToken, depositToken, sourceChainId, chainId, assetAddress, assetToken, selectedExtraToken]) const destinationToken = useMemo(() => { if (isAutoStakingEnabled && stakingAddress) return stakingAddress @@ -120,29 +192,55 @@ export const WidgetDeposit: FC = ({ // ============================================================================ const depositInput = useDebouncedInput(inputToken?.decimals ?? 18) const [depositAmount, , setDepositInput] = depositInput + const shouldCollapseDetails = Boolean(collapseDetails && !hideDetails && !hideActionButton) + + useEffect(() => { + onAmountChange?.(depositAmount.formValue) + }, [depositAmount.formValue, onAmountChange]) + + useEffect(() => { + onTokenSelectionChange?.(depositToken, sourceChainId) + }, [depositToken, onTokenSelectionChange, sourceChainId]) useEffect(() => { if (!prefill) return const key = `${prefill.address}-${prefill.chainId}-${prefill.amount}` if (appliedPrefillRef.current === key) return appliedPrefillRef.current = key - setSelectedToken(prefill.address) - setSelectedChainId(prefill.chainId) + + const canApplyPrefilledToken = + ensoEnabled || (toAddress(prefill.address) === toAddress(assetAddress) && prefill.chainId === chainId) + + setSelectedToken(canApplyPrefilledToken ? prefill.address : assetAddress) + setSelectedChainId(canApplyPrefilledToken ? prefill.chainId : undefined) if (prefill.amount !== undefined) { setDepositInput(prefill.amount) } onPrefillApplied?.() - }, [prefill, setDepositInput, onPrefillApplied]) + }, [prefill, ensoEnabled, assetAddress, chainId, setDepositInput, onPrefillApplied]) + + useResetEnsoSelection({ + ensoEnabled, + selectedToken, + selectedChainId, + assetAddress, + chainId, + showTokenSelector, + setSelectedToken, + setSelectedChainId, + setShowTokenSelector + }) useEffect(() => { - if (!collapseDetails && isDetailsPanelOpen) { + if (!shouldCollapseDetails && isDetailsPanelOpen) { setIsDetailsPanelOpen(false) } - }, [collapseDetails, isDetailsPanelOpen]) + }, [isDetailsPanelOpen, shouldCollapseDetails]) const { routeType, activeFlow } = useDepositFlow({ depositToken, assetAddress, + directDepositTokenAddress, destinationToken, vaultAddress, stakingAddress, @@ -232,12 +330,50 @@ export const WidgetDeposit: FC = ({ : 0n const formatted = formatWidgetValue(valueInAsset, assetDecimals) + const usdRaw = Number(formatUnits(valueInAsset, assetDecimals)) * assetTokenPrice + const usd = formatWidgetValue(usdRaw) - const usd = formatWidgetValue(Number(formatUnits(valueInAsset, assetDecimals)) * assetTokenPrice) - - return { formatted, usd } + return { formatted, usd, usdRaw } }, [activeFlow.periphery.expectedOut, vaultDecimals, assetToken?.decimals, pricePerShare, assetTokenPrice]) + // Calculate price impact for high slippage warning + const priceImpactInfo = useMemo(() => { + const usdValueToDeposit = Number(formatUnits(depositAmount.bn, inputToken?.decimals ?? 18)) * inputTokenPrice + const vaultShareUsdValue = vaultShareValue.usdRaw + const impact = + usdValueToDeposit > 0 && vaultShareUsdValue > 0 + ? ((usdValueToDeposit - vaultShareUsdValue) / usdValueToDeposit) * 100 + : 0 + return { + percentage: impact, + isHigh: impact > 5 + } + }, [depositAmount.bn, inputToken?.decimals, inputTokenPrice, vaultShareValue.usdRaw]) + + const priceImpactAcceptanceKey = useMemo(() => { + return [ + depositAmount.bn.toString(), + routeType, + sourceChainId, + depositToken, + destinationToken, + activeFlow.periphery.routerAddress ?? '', + activeFlow.periphery.expectedOut.toString() + ].join(':') + }, [ + depositAmount.bn, + routeType, + sourceChainId, + depositToken, + destinationToken, + activeFlow.periphery.routerAddress, + activeFlow.periphery.expectedOut + ]) + + useEffect(() => { + setHasAcceptedPriceImpact(false) + }, [priceImpactAcceptanceKey]) + const formattedDepositAmount = formatTAmount({ value: depositAmount.bn, decimals: inputToken?.decimals ?? 18 }) const needsApproval = !isNativeToken && !activeFlow.periphery.isAllowanceSufficient @@ -249,20 +385,21 @@ export const WidgetDeposit: FC = ({ confirmMessage: `Approving ${formattedDepositAmount} ${inputToken?.symbol || ''}`, successTitle: 'Approval successful', successMessage: `Approved ${formattedDepositAmount} ${inputToken?.symbol || ''}.\nReady to deposit.`, + completesFlow: false, notification: approveNotificationParams } } - const actionVerb = routeType === 'DIRECT_STAKE' ? 'Stake' : 'Deposit' - const actionVerbPast = routeType === 'DIRECT_STAKE' ? 'staked' : 'deposited' + const { actionLabel, progressLabel, pastTenseLabel } = getDepositActionCopy(routeType) if (isCrossChain) { return { prepare: activeFlow.actions.prepareDeposit, - label: actionVerb, - confirmMessage: `${routeType === 'DIRECT_STAKE' ? 'Staking' : 'Depositing'} ${formattedDepositAmount} ${inputToken?.symbol || ''}`, + label: actionLabel, + confirmMessage: `${progressLabel} ${formattedDepositAmount} ${inputToken?.symbol || ''}`, successTitle: 'Transaction Submitted', - successMessage: `Your cross-chain ${actionVerb.toLowerCase()} has been submitted.\nIt may take a few minutes to complete on the destination chain.`, + successMessage: `Your cross-chain ${actionLabel.toLowerCase()} has been submitted.\nIt may take a few minutes to complete on the destination chain.`, + completesFlow: true, showConfetti: true, notification: depositNotificationParams } @@ -270,10 +407,11 @@ export const WidgetDeposit: FC = ({ return { prepare: activeFlow.actions.prepareDeposit, - label: actionVerb, - confirmMessage: `${routeType === 'DIRECT_STAKE' ? 'Staking' : 'Depositing'} ${formattedDepositAmount} ${inputToken?.symbol || ''}`, - successTitle: `${actionVerb} successful!`, - successMessage: `You have ${actionVerbPast} ${formattedDepositAmount} ${inputToken?.symbol || ''} into ${vaultSymbol}.`, + label: actionLabel, + confirmMessage: `${progressLabel} ${formattedDepositAmount} ${inputToken?.symbol || ''}`, + successTitle: `${actionLabel} successful!`, + successMessage: `You have ${pastTenseLabel} ${formattedDepositAmount} ${inputToken?.symbol || ''} into ${vaultSymbol}.`, + completesFlow: true, showConfetti: true, notification: depositNotificationParams } @@ -305,11 +443,8 @@ export const WidgetDeposit: FC = ({ const handleDepositSuccess = useCallback(() => { const amountToDeposit = formatUnits(depositAmount.bn, inputToken?.decimals ?? 18) - const priceUsd = - inputToken?.address && inputToken?.chainID - ? getPrice({ address: toAddress(inputToken.address), chainID: inputToken.chainID }).normalized - : 0 - const valueUsd = Number(amountToDeposit) * priceUsd + const priceUsd = inputTokenPrice + const valueUsd = Number(amountToDeposit) * inputTokenPrice trackEvent(PLAUSIBLE_EVENTS.DEPOSIT, { props: { @@ -340,10 +475,8 @@ export const WidgetDeposit: FC = ({ }, [ depositAmount.bn, inputToken?.decimals, - inputToken?.address, - inputToken?.chainID, inputToken?.symbol, - getPrice, + inputTokenPrice, trackEvent, chainId, vaultAddress, @@ -371,7 +504,7 @@ export const WidgetDeposit: FC = ({ if (isLoadingVaultData) { return (
- +
@@ -383,15 +516,26 @@ export const WidgetDeposit: FC = ({ // Render // ============================================================================ const isSettingsVisible = !!account && !!isSettingsOpen + const approvalSpenderName = !isNativeToken ? (routeType === 'ENSO' ? 'Enso' : 'Vault') : undefined + const onAllowanceClick = + !isNativeToken && activeFlow.periphery.allowance > 0n + ? (): void => { + setDepositInput(formatUnits(activeFlow.periphery.allowance, inputToken?.decimals ?? 18)) + } + : undefined - const detailsSection = ( + const detailsSection = detailsContent ? ( + detailsContent + ) : hideDetails ? null : ( = ({ pricePerShare={pricePerShare || 0n} assetUsdPrice={assetTokenPrice} willReceiveStakedShares={willReceiveStakedShares} + vaultSharesLabel={vaultSharesLabel} onShowVaultSharesModal={() => setShowVaultSharesModal(true)} onShowVaultShareValueModal={() => setShowVaultShareValueModal(true)} estimatedAnnualReturn={estimatedAnnualReturn} @@ -408,81 +553,104 @@ export const WidgetDeposit: FC = ({ allowance={!isNativeToken ? activeFlow.periphery.allowance : undefined} allowanceTokenDecimals={!isNativeToken ? (inputToken?.decimals ?? 18) : undefined} allowanceTokenSymbol={!isNativeToken ? inputToken?.symbol : undefined} - approvalSpenderName={!isNativeToken ? (routeType === 'ENSO' ? 'Enso' : 'Vault') : undefined} - onAllowanceClick={ - !isNativeToken && activeFlow.periphery.allowance > 0n - ? () => setDepositInput(formatUnits(activeFlow.periphery.allowance, inputToken?.decimals ?? 18)) - : undefined - } + approvalSpenderName={approvalSpenderName} + onAllowanceClick={onAllowanceClick} onShowApprovalOverlay={!isNativeToken ? () => setShowApprovalOverlay(true) : undefined} /> ) - const actionRow = ( -
-
- {!account ? ( - - ) : ( - - )} + const priceImpactWarning = priceImpactInfo.isHigh && + !activeFlow.periphery.isLoadingRoute && + !depositAmount.isDebouncing && + depositAmount.bn === depositAmount.debouncedBn && + depositAmount.bn > 0n && ( +
+

+ Price impact is high ({priceImpactInfo.percentage.toFixed(2)}%). Consider depositing less or waiting for + better liquidity conditions. +

+
- {account && onOpenSettings ? ( - + ) : ( + )} - > - - - ) : null} +
+ {showSettingsButton ? ( + + ) : null} +
- ) + ) : null return (
- -
+ +
{/* Amount Section */} = ({ onTokenSelectorClick={() => setShowTokenSelector(true)} /> - {collapseDetails ? ( + {contentBelowInput} + + {shouldCollapseDetails ? ( <>
- {collapseDetails && isDetailsPanelOpen ? ( + {shouldCollapseDetails && isDetailsPanelOpen ? (
Your Transaction Details @@ -555,6 +725,7 @@ export const WidgetDeposit: FC = ({ onClose={() => setShowTransactionOverlay(false)} step={currentStep} isLastStep={!needsApproval} + deferOnAllCompleteUntilClose={routeType === 'ENSO'} autoContinueToNextStep autoContinueStepLabels={['Approve', 'Sign Permit']} onAllComplete={handleDepositSuccess} @@ -624,6 +795,7 @@ export const WidgetDeposit: FC = ({ value={selectedToken} priorityTokens={{ [chainId]: [assetAddress] }} excludeTokens={stakingAddress ? [stakingAddress] : [vaultAddress]} + extraTokens={tokenSelectorExtraTokens} assetAddress={assetAddress} vaultAddress={vaultAddress} stakingAddress={stakingAddress} diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts b/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts index f7c6e2bf6..aa6d24ddc 100644 --- a/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts +++ b/src/components/pages/vaults/components/widget/deposit/useDepositFlow.ts @@ -1,6 +1,9 @@ import { useDirectDeposit } from '@pages/vaults/hooks/actions/useDirectDeposit' import { useDirectStake } from '@pages/vaults/hooks/actions/useDirectStake' import { useEnsoDeposit } from '@pages/vaults/hooks/actions/useEnsoDeposit' +import { useYvUsdLockedZapDeposit } from '@pages/vaults/hooks/actions/useYvUsdLockedZapDeposit' +import { YVUSD_LOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { toAddress } from '@shared/utils' import { useMemo } from 'react' import type { Address } from 'viem' import type { DepositRouteType } from './types' @@ -10,6 +13,7 @@ interface UseDepositFlowProps { // Token addresses depositToken: Address assetAddress: Address + directDepositTokenAddress?: Address destinationToken: Address vaultAddress: Address stakingAddress?: Address @@ -53,6 +57,7 @@ export interface DepositFlowResult { export const useDepositFlow = ({ depositToken, assetAddress, + directDepositTokenAddress, destinationToken, vaultAddress, stakingAddress, @@ -69,13 +74,24 @@ export const useDepositFlow = ({ }: UseDepositFlowProps): DepositFlowResult => { // Determine routing type const routeType = useDepositRoute({ + chainId, depositToken, assetAddress, + directDepositTokenAddress, destinationToken, vaultAddress, stakingAddress }) + const isYvUsdLockedZapDeposit = useMemo( + () => + routeType === 'DIRECT_DEPOSIT' && + !!directDepositTokenAddress && + toAddress(vaultAddress) === YVUSD_LOCKED_ADDRESS && + toAddress(depositToken) === toAddress(directDepositTokenAddress), + [routeType, directDepositTokenAddress, vaultAddress, depositToken] + ) + // Direct deposit flow (asset → vault) const directDeposit = useDirectDeposit({ vaultAddress, @@ -84,7 +100,15 @@ export const useDepositFlow = ({ account, chainId, decimals: inputDecimals, - enabled: routeType === 'DIRECT_DEPOSIT' && amount > 0n + enabled: routeType === 'DIRECT_DEPOSIT' && amount > 0n && !isYvUsdLockedZapDeposit + }) + + const yvUsdLockedZapDeposit = useYvUsdLockedZapDeposit({ + depositToken, + amount, + account, + chainId, + enabled: isYvUsdLockedZapDeposit && amount > 0n }) // Direct stake flow (vault → staking) @@ -115,10 +139,12 @@ export const useDepositFlow = ({ // Select active flow based on routing type const activeFlow = useMemo(() => { - if (routeType === 'DIRECT_DEPOSIT') return directDeposit + if (routeType === 'DIRECT_DEPOSIT') { + return isYvUsdLockedZapDeposit ? yvUsdLockedZapDeposit : directDeposit + } if (routeType === 'DIRECT_STAKE') return directStake return ensoFlow - }, [routeType, directDeposit, directStake, ensoFlow]) + }, [routeType, isYvUsdLockedZapDeposit, yvUsdLockedZapDeposit, directDeposit, directStake, ensoFlow]) return { routeType, diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositNotifications.ts b/src/components/pages/vaults/components/widget/deposit/useDepositNotifications.ts index d4725a924..b71596758 100644 --- a/src/components/pages/vaults/components/widget/deposit/useDepositNotifications.ts +++ b/src/components/pages/vaults/components/widget/deposit/useDepositNotifications.ts @@ -64,8 +64,8 @@ export const useDepositNotifications = ({ spenderAddress = stakingAddress || destinationToken spenderName = stakingToken?.symbol || 'Staking Contract' } else if (routeType === 'DIRECT_DEPOSIT') { - spenderAddress = destinationToken - spenderName = vault.symbol || 'Vault' + spenderAddress = (routerAddress as Address) || destinationToken + spenderName = routerAddress ? 'Vault Zap' : vault.symbol || 'Vault' } else { return undefined } diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts b/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts new file mode 100644 index 000000000..a782a2c7b --- /dev/null +++ b/src/components/pages/vaults/components/widget/deposit/useDepositRoute.test.ts @@ -0,0 +1,62 @@ +import type { Address } from 'viem' +import { describe, expect, it } from 'vitest' +import { resolveDepositRouteType } from './useDepositRoute' + +const ASSET = '0x0000000000000000000000000000000000000001' as Address +const VAULT = '0x0000000000000000000000000000000000000002' as Address +const STAKING = '0x0000000000000000000000000000000000000003' as Address +const OTHER = '0x0000000000000000000000000000000000000004' as Address + +describe('resolveDepositRouteType', () => { + it('returns DIRECT_DEPOSIT for asset-to-vault deposits', () => { + const route = resolveDepositRouteType({ + depositToken: ASSET, + assetAddress: ASSET, + destinationToken: VAULT, + vaultAddress: VAULT, + stakingAddress: STAKING, + ensoEnabled: false + }) + + expect(route).toBe('DIRECT_DEPOSIT') + }) + + it('returns DIRECT_STAKE for vault-to-staking deposits', () => { + const route = resolveDepositRouteType({ + depositToken: VAULT, + assetAddress: ASSET, + destinationToken: STAKING, + vaultAddress: VAULT, + stakingAddress: STAKING, + ensoEnabled: false + }) + + expect(route).toBe('DIRECT_STAKE') + }) + + it('returns ENSO for non-direct routes when Enso is enabled', () => { + const route = resolveDepositRouteType({ + depositToken: OTHER, + assetAddress: ASSET, + destinationToken: VAULT, + vaultAddress: VAULT, + stakingAddress: STAKING, + ensoEnabled: true + }) + + expect(route).toBe('ENSO') + }) + + it('returns NO_ROUTE for non-direct routes when Enso is disabled', () => { + const route = resolveDepositRouteType({ + depositToken: OTHER, + assetAddress: ASSET, + destinationToken: VAULT, + vaultAddress: VAULT, + stakingAddress: STAKING, + ensoEnabled: false + }) + + expect(route).toBe('NO_ROUTE') + }) +}) diff --git a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.ts b/src/components/pages/vaults/components/widget/deposit/useDepositRoute.ts index 4a4206123..dbf5bcfc8 100644 --- a/src/components/pages/vaults/components/widget/deposit/useDepositRoute.ts +++ b/src/components/pages/vaults/components/widget/deposit/useDepositRoute.ts @@ -1,54 +1,84 @@ import { useEnsoEnabled } from '@pages/vaults/hooks/useEnsoEnabled' -import { toAddress } from '@shared/utils' import { useMemo } from 'react' -import type { Address } from 'viem' +import { type Address, isAddressEqual } from 'viem' import type { DepositRouteType } from './types' interface UseDepositRouteProps { + chainId: number depositToken: Address assetAddress: Address + directDepositTokenAddress?: Address destinationToken: Address vaultAddress: Address stakingAddress?: Address } +interface ResolveDepositRouteTypeProps extends Omit { + ensoEnabled: boolean +} + +export function resolveDepositRouteType({ + depositToken, + assetAddress, + directDepositTokenAddress, + destinationToken, + vaultAddress, + stakingAddress, + ensoEnabled +}: ResolveDepositRouteTypeProps): DepositRouteType { + // Case 1: Direct vault deposit (asset → vault) + if ( + (isAddressEqual(depositToken, assetAddress) || + (!!directDepositTokenAddress && isAddressEqual(depositToken, directDepositTokenAddress))) && + isAddressEqual(destinationToken, vaultAddress) + ) { + return 'DIRECT_DEPOSIT' + } + + // Case 2: Direct staking (vault → staking) + if ( + stakingAddress && + isAddressEqual(depositToken, vaultAddress) && + isAddressEqual(destinationToken, stakingAddress) + ) { + return 'DIRECT_STAKE' + } + + // Case 3: All other cases use Enso (if available) + if (ensoEnabled) { + return 'ENSO' + } + return 'NO_ROUTE' +} + /** * Determines the routing type for a deposit transaction. * - DIRECT_DEPOSIT: asset → vault (simple deposit) * - DIRECT_STAKE: vault → staking (stake vault tokens) * - ENSO: all other cases (zaps, cross-chain, etc.) */ -export const useDepositRoute = ({ +export function useDepositRoute({ + chainId, depositToken, assetAddress, + directDepositTokenAddress, destinationToken, vaultAddress, stakingAddress -}: UseDepositRouteProps): DepositRouteType => { - const ensoEnabled = useEnsoEnabled() - - return useMemo(() => { - // Case 1: Direct vault deposit (asset → vault) - if ( - toAddress(depositToken) === toAddress(assetAddress) && - toAddress(destinationToken) === toAddress(vaultAddress) - ) { - return 'DIRECT_DEPOSIT' - } - - // Case 2: Direct staking (vault → staking) - if ( - toAddress(depositToken) === toAddress(vaultAddress) && - stakingAddress && - toAddress(destinationToken) === toAddress(stakingAddress) - ) { - return 'DIRECT_STAKE' - } +}: UseDepositRouteProps): DepositRouteType { + const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) - // Case 3: All other cases use Enso - if (ensoEnabled) { - return 'ENSO' - } - return 'NO_ROUTE' - }, [ensoEnabled, depositToken, assetAddress, destinationToken, vaultAddress, stakingAddress]) + return useMemo( + () => + resolveDepositRouteType({ + depositToken, + assetAddress, + directDepositTokenAddress, + destinationToken, + vaultAddress, + stakingAddress, + ensoEnabled + }), + [ensoEnabled, depositToken, assetAddress, directDepositTokenAddress, destinationToken, vaultAddress, stakingAddress] + ) } diff --git a/src/components/pages/vaults/components/widget/index.tsx b/src/components/pages/vaults/components/widget/index.tsx index 5c67e5584..85f940577 100644 --- a/src/components/pages/vaults/components/widget/index.tsx +++ b/src/components/pages/vaults/components/widget/index.tsx @@ -12,7 +12,15 @@ import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' import { WidgetActionType as ActionType } from '@pages/vaults/types' import type { TAddress } from '@shared/types' import { cl, isZeroAddress, toAddress } from '@shared/utils' -import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react' +import { + type ForwardedRef, + forwardRef, + type ReactElement, + type ReactNode, + useEffect, + useImperativeHandle, + useState +} from 'react' import { WidgetDeposit } from './deposit' import { WidgetMigrate } from './migrate' import { WidgetWithdraw } from './withdraw' @@ -57,171 +65,154 @@ const getActionLabel = (action: ActionType): string => { } } -export const Widget = forwardRef( - ( - { - currentVault, - vaultAddress, - gaugeAddress, - disableDepositStaking, - actions, - chainId, - vaultUserData, - handleSuccess, - mode, - onModeChange, - showTabs = true, - onOpenSettings, - isSettingsOpen, - depositPrefill, - onDepositPrefillConsumed, - hideTabSelector, - disableBorderRadius, - collapseDetails - }, - ref - ) => { - const [internalMode, setInternalMode] = useState(actions[0]) - const currentMode = mode ?? internalMode - const setMode = onModeChange ?? setInternalMode - const assetToken = getVaultToken(currentVault).address - const vaultAPR = getVaultAPR(currentVault) - const vaultSymbol = getVaultSymbol(currentVault) - const vaultStaking = getVaultStaking(currentVault) - const vaultVersion = getVaultVersion(currentVault) - const vaultInfo = getVaultInfo(currentVault) - const vaultMigration = getVaultMigration(currentVault) - const resolvedStakingAddress = isZeroAddress(gaugeAddress) ? undefined : toAddress(gaugeAddress) - - useImperativeHandle(ref, () => ({ - setMode: (newMode: ActionType) => { - if (actions.includes(newMode)) { - setMode(newMode) - } - } - })) +export const Widget = forwardRef(function Widget( + { + currentVault, + vaultAddress, + gaugeAddress, + disableDepositStaking, + actions, + chainId, + vaultUserData, + handleSuccess, + mode, + onModeChange, + showTabs = true, + onOpenSettings, + isSettingsOpen, + depositPrefill, + onDepositPrefillConsumed, + hideTabSelector, + disableBorderRadius, + collapseDetails + }: Props, + ref: ForwardedRef +): ReactElement { + const [internalMode, setInternalMode] = useState(actions[0]) + const currentMode = mode ?? internalMode + const setMode = onModeChange ?? setInternalMode + const assetToken = getVaultToken(currentVault).address + const vaultAPR = getVaultAPR(currentVault) + const vaultSymbol = getVaultSymbol(currentVault) + const vaultStaking = getVaultStaking(currentVault) + const vaultVersion = getVaultVersion(currentVault) + const vaultInfo = getVaultInfo(currentVault) + const vaultMigration = getVaultMigration(currentVault) + const resolvedStakingAddress = isZeroAddress(gaugeAddress) ? undefined : toAddress(gaugeAddress) - useEffect(() => { - if (mode === undefined) { - setInternalMode(actions[0]) + useImperativeHandle(ref, () => ({ + setMode(newMode: ActionType): void { + if (actions.includes(newMode)) { + setMode(newMode) } - }, [actions, mode]) + } + })) - const SelectedComponent = useMemo(() => { - switch (currentMode) { - case ActionType.Deposit: - return ( - - ) - case ActionType.Withdraw: - return ( - - ) - case ActionType.Migrate: - return ( - - ) - } - }, [ - currentMode, - vaultAddress, - disableDepositStaking, - currentVault, - assetToken, - chainId, - vaultUserData, - handleSuccess, - depositPrefill, - onDepositPrefillConsumed, - onOpenSettings, - isSettingsOpen, - hideTabSelector, - disableBorderRadius, - resolvedStakingAddress, - collapseDetails - ]) + useEffect(() => { + if (mode === undefined) { + setInternalMode(actions[0]) + } + }, [actions, mode]) - // Mobile mode: simple layout without tabs - if (hideTabSelector) { - return ( -
-
- {SelectedComponent} -
-
- ) + function renderSelectedComponent(): ReactElement { + switch (currentMode) { + case ActionType.Deposit: + return ( + + ) + case ActionType.Withdraw: + return ( + + ) + case ActionType.Migrate: + return ( + + ) } + } + const selectedComponent = renderSelectedComponent() + + if (hideTabSelector) { return ( -
+
- {showTabs ? ( - - ) : null} -
{SelectedComponent}
+ {selectedComponent}
) } -) -export const WidgetTabs: FC<{ + return ( +
+
+ {showTabs ? ( + + ) : null} +
{selectedComponent}
+
+
+ ) +}) + +type WidgetTabsProps = { actions: ActionType[] activeAction: ActionType onActionChange: (action: ActionType) => void @@ -231,7 +222,10 @@ export const WidgetTabs: FC<{ onCloseOverlays?: () => void disableBorderRadius?: boolean dataTour?: string -}> = ({ + walletDataTour?: string +} + +export function WidgetTabs({ actions, activeAction, onActionChange, @@ -240,9 +234,11 @@ export const WidgetTabs: FC<{ isWalletOpen, onCloseOverlays, disableBorderRadius, - dataTour -}) => { + dataTour, + walletDataTour +}: WidgetTabsProps): ReactElement { const isWalletTabActive = !!isWalletOpen + return (
{'My Info'} @@ -278,13 +274,15 @@ export const WidgetTabs: FC<{ ) } -const TabButton: FC<{ +type TabButtonProps = { className?: string - children: React.ReactNode + children: ReactNode onClick: () => void isActive: boolean dataTour?: string -}> = ({ children, onClick, isActive, className, dataTour }) => { +} + +function TabButton({ children, onClick, isActive, className, dataTour }: TabButtonProps): ReactElement { return ( )} diff --git a/src/components/pages/vaults/components/widget/shared/WidgetHeader.tsx b/src/components/pages/vaults/components/widget/shared/WidgetHeader.tsx index 99b40b9bd..bc7d1fc3b 100644 --- a/src/components/pages/vaults/components/widget/shared/WidgetHeader.tsx +++ b/src/components/pages/vaults/components/widget/shared/WidgetHeader.tsx @@ -1,7 +1,15 @@ -import type { FC } from 'react' +import type { ReactElement, ReactNode } from 'react' -export const WidgetHeader: FC<{ title: string }> = ({ title }) => ( -
-

{title}

-
-) +type WidgetHeaderProps = { + title: string + actions?: ReactNode +} + +export function WidgetHeader({ title, actions }: WidgetHeaderProps): ReactElement { + return ( +
+

{title}

+ {actions ?
{actions}
: null} +
+ ) +} 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/shared/useResetEnsoSelection.ts b/src/components/pages/vaults/components/widget/shared/useResetEnsoSelection.ts new file mode 100644 index 000000000..7f7ca0455 --- /dev/null +++ b/src/components/pages/vaults/components/widget/shared/useResetEnsoSelection.ts @@ -0,0 +1,59 @@ +import { type Dispatch, type SetStateAction, useEffect } from 'react' +import { type Address, isAddressEqual } from 'viem' + +interface UseResetEnsoSelectionParams { + ensoEnabled: boolean + selectedToken?: Address + selectedChainId?: number + assetAddress: Address + chainId: number + showTokenSelector: boolean + setSelectedToken: Dispatch> + setSelectedChainId: Dispatch> + setShowTokenSelector: Dispatch> +} + +export function useResetEnsoSelection({ + ensoEnabled, + selectedToken, + selectedChainId, + assetAddress, + chainId, + showTokenSelector, + setSelectedToken, + setSelectedChainId, + setShowTokenSelector +}: UseResetEnsoSelectionParams): void { + useEffect(() => { + if (ensoEnabled) { + return + } + + const hasNonAssetTokenSelected = selectedToken !== undefined && !isAddressEqual(selectedToken, assetAddress) + const hasCrossChainSelection = selectedChainId !== undefined && selectedChainId !== chainId + + if (!hasNonAssetTokenSelected && !hasCrossChainSelection && !showTokenSelector) { + return + } + + if (hasNonAssetTokenSelected) { + setSelectedToken(assetAddress) + } + if (hasCrossChainSelection) { + setSelectedChainId(undefined) + } + if (showTokenSelector) { + setShowTokenSelector(false) + } + }, [ + ensoEnabled, + selectedToken, + selectedChainId, + assetAddress, + chainId, + showTokenSelector, + setSelectedToken, + setSelectedChainId, + setShowTokenSelector + ]) +} diff --git a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx index ee669cab8..f899af62f 100644 --- a/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx +++ b/src/components/pages/vaults/components/widget/withdraw/WithdrawDetails.tsx @@ -1,4 +1,5 @@ -import type { FC } from 'react' +import type { ReactElement } from 'react' +import { formatUnits } from 'viem' import { formatWidgetAllowance, formatWidgetValue } from '../shared/valueDisplay' import type { WithdrawRouteType } from './types' @@ -9,6 +10,7 @@ interface WithdrawDetailsProps { requiredShares: bigint sharesDecimals: number isLoadingQuote: boolean + isQuoteStale: boolean // Output info expectedOut: bigint outputDecimals: number @@ -16,7 +18,12 @@ interface WithdrawDetailsProps { // Optional swap info showSwapRow: boolean withdrawAmountSimple: string + withdrawAmountBn: bigint + assetDecimals: number + assetUsdPrice: number assetSymbol?: string + // Output USD price for slippage calculation + outputUsdPrice: number // Route type for "at least" text routeType: WithdrawRouteType // Modal trigger @@ -25,28 +32,49 @@ interface WithdrawDetailsProps { allowance?: bigint allowanceTokenDecimals?: number allowanceTokenSymbol?: string + approvalSpenderName?: string onAllowanceClick?: () => void + onShowApprovalOverlay?: () => void } -export const WithdrawDetails: FC = ({ +function getApprovalLabel(approvalSpenderName?: string): string { + return approvalSpenderName ? `Existing Approval (${approvalSpenderName})` : 'Existing Approval' +} + +export function WithdrawDetails({ actionLabel, requiredShares, sharesDecimals, isLoadingQuote, + isQuoteStale, expectedOut, outputDecimals, outputSymbol, showSwapRow, withdrawAmountSimple, + withdrawAmountBn, + assetDecimals, + assetUsdPrice, assetSymbol, + outputUsdPrice, routeType, onShowDetailsModal, allowance, allowanceTokenDecimals, allowanceTokenSymbol, - onAllowanceClick -}) => { + approvalSpenderName, + onAllowanceClick, + onShowApprovalOverlay +}: WithdrawDetailsProps): ReactElement { const allowanceDisplay = formatWidgetAllowance(allowance, allowanceTokenDecimals) + const approvalLabel = getApprovalLabel(approvalSpenderName) + const withdrawUsdValue = Number(formatUnits(withdrawAmountBn, assetDecimals)) * assetUsdPrice + const expectedOutUsdValue = Number(formatUnits(expectedOut, outputDecimals)) * outputUsdPrice + const priceImpact = + withdrawUsdValue > 0 && expectedOutUsdValue > 0 + ? ((withdrawUsdValue - expectedOutUsdValue) / withdrawUsdValue) * 100 + : 0 + const hasHighPriceImpact = !isQuoteStale && !isLoadingQuote && priceImpact > 5 return (
@@ -88,13 +116,14 @@ export const WithdrawDetails: FC = ({

You will receive{routeType === 'ENSO' ? ' at least' : ''}

-

+

{isLoadingQuote ? ( ) : expectedOut > 0n ? ( <> {formatWidgetValue(expectedOut, outputDecimals)}{' '} {outputSymbol} + {hasHighPriceImpact && {` (-${priceImpact.toFixed(2)}%)`}} ) : ( <> @@ -109,7 +138,17 @@ export const WithdrawDetails: FC = ({ {/* Approved allowance (for zap withdrawals) */} {allowanceDisplay && (

-

Existing Approval

+ {onShowApprovalOverlay ? ( + + ) : ( +

{approvalLabel}

+ )} {onAllowanceClick && allowanceDisplay !== 'Unlimited' ? ( - ) : ( - + ) : ( + + )} +
+ {showSettingsButton ? ( + - )} + + + ) : null}
- {account && onOpenSettings ? ( - - ) : null}
) return (
- -
+ +
{/* Withdraw From Selector */} {hasBothBalances && } @@ -510,64 +853,59 @@ export const WidgetWithdraw: FC< input={withdrawInput} title="Amount" placeholder="0.00" - balance={totalBalanceInUnderlying.raw} + balance={effectiveMaxWithdrawAssets} decimals={assetToken?.decimals ?? 18} symbol={assetToken?.symbol || 'tokens'} - disabled={!!hasBothBalances && !withdrawalSource} - errorMessage={withdrawError || undefined} + disabled={disableAmountInput || (!!hasBothBalances && !withdrawalSource)} + errorMessage={effectiveWithdrawError || undefined} inputTokenUsdPrice={assetTokenPrice} outputTokenUsdPrice={outputTokenPrice} tokenAddress={assetToken?.address} tokenChainId={assetToken?.chainID} - showTokenSelector={ensoEnabled && withdrawToken === assetAddress} - onTokenSelectorClick={() => setShowTokenSelector(true)} + showTokenSelector={canShowAssetTokenSelector} + onTokenSelectorClick={canOpenTokenSelector ? () => setShowTokenSelector(true) : undefined} onInputChange={(value: bigint) => { - if (value === totalBalanceInUnderlying.raw) { - const exactAmount = formatUnits(totalBalanceInUnderlying.raw, assetToken?.decimals ?? 18) + if (value === effectiveMaxWithdrawAssets) { + const exactAmount = formatUnits(effectiveMaxWithdrawAssets, assetToken?.decimals ?? 18) withdrawInput[2](exactAmount) } }} zapToken={zapToken} - onRemoveZap={() => { - setSelectedToken(assetAddress) - setSelectedChainId(chainId) - }} - zapNotificationText={ - isUnstake - ? 'This transaction will unstake' - : withdrawToken !== assetAddress - ? '⚡ This transaction will use Enso to Zap to:' - : undefined - } + onRemoveZap={onRemoveZap} + zapNotificationText={zapNotificationText} />
+ + {contentBelowInput}
- {collapseDetails ? ( - <> - - {actionRow} - - ) : ( - <> - {/* Details Section */} - {detailsSection} - - {/* Action Button */} - {actionRow} - - )} + {!hideActionButton ? ( + collapseDetails ? ( + <> + + {!hideActionButton ? actionRow : null} + + ) : ( + <> + {/* Details Section */} + {detailsSection} + + {/* Action Button */} + {!hideActionButton ? actionRow : null} + + ) + ) : null}
- {collapseDetails && isDetailsPanelOpen ? ( + {collapseDetails && isDetailsPanelOpen && !hideActionButton ? (
Your Transaction Details @@ -581,7 +919,7 @@ export const WidgetWithdraw: FC<
{detailsSection}
-
{actionRow}
+ {!hideActionButton ?
{actionRow}
: null}
) : null} @@ -594,9 +932,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} /> @@ -607,18 +946,31 @@ export const WidgetWithdraw: FC< sourceTokenSymbol={withdrawalSource === 'staking' ? stakingToken?.symbol || vaultSymbol : vaultSymbol} vaultAssetSymbol={assetToken?.symbol || ''} outputTokenSymbol={outputToken?.symbol || ''} - withdrawAmount={requiredShares > 0n ? formatWidgetValue(requiredShares, sharesDecimals) : '0'} + withdrawAmount={effectiveRequiredShares > 0n ? formatWidgetValue(effectiveRequiredShares, sharesDecimals) : '0'} expectedOutput={ - activeFlow.periphery.expectedOut > 0n - ? formatWidgetValue(activeFlow.periphery.expectedOut, outputToken?.decimals ?? 18) - : undefined + effectiveExpectedOut > 0n ? formatWidgetValue(effectiveExpectedOut, outputToken?.decimals ?? 18) : undefined } hasInputValue={withdrawAmount.bn > 0n} stakingAddress={stakingAddress} withdrawalSource={withdrawalSource} routeType={routeType} - isZap={routeType === 'ENSO' && selectedToken !== assetAddress} - isLoadingQuote={activeFlow.periphery.isLoadingRoute} + isZap={routeType === 'ENSO' && shouldShowZapUi} + isLoadingQuote={isFetchingQuote} + /> + + { + setShowApprovalOverlay(false) + setOptimisticApprovedShares(null) + }} + tokenSymbol={approvalState.tokenSymbol || ''} + tokenAddress={toAddress(sourceToken)} + tokenDecimals={approvalState.tokenDecimals} + spenderAddress={approvalState.spenderAddress} + spenderName={approvalState.spenderName || 'Vault'} + chainId={chainId} + currentAllowance={formatWidgetAllowance(activeFlow.periphery.allowance, approvalState.tokenDecimals) || '0'} /> {/* Full-screen Token Selector Overlay */} @@ -636,7 +988,7 @@ export const WidgetWithdraw: FC< value={selectedToken} excludeTokens={stakingAddress ? [stakingAddress] : undefined} priorityTokens={priorityTokens} - assetAddress={assetAddress} + assetAddress={resolvedDisplayAssetAddress} vaultAddress={vaultAddress} stakingAddress={stakingAddress} /> diff --git a/src/components/pages/vaults/components/widget/withdraw/types.ts b/src/components/pages/vaults/components/widget/withdraw/types.ts index bf9e053b2..bee0233b7 100644 --- a/src/components/pages/vaults/components/widget/withdraw/types.ts +++ b/src/components/pages/vaults/components/widget/withdraw/types.ts @@ -1,18 +1,39 @@ import type { VaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import type { ReactNode } from 'react' -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 export interface WithdrawWidgetProps { vaultAddress: `0x${string}` assetAddress: `0x${string}` + displayAssetAddress?: `0x${string}` stakingAddress?: `0x${string}` chainId: number vaultSymbol: string + stakingSource?: string vaultVersion?: string isVaultRetired?: boolean vaultUserData: VaultUserData + maxWithdrawAssets?: bigint + requiredSharesOverride?: bigint + expectedOutOverride?: bigint + isActionDisabled?: boolean + actionDisabledReason?: string + disableTokenSelector?: boolean + hideZapForTokens?: `0x${string}`[] + disableAmountInput?: boolean + hideActionButton?: boolean + prefill?: { + address: `0x${string}` + chainId: number + amount?: string + } + prefillRequestKey?: string + onPrefillApplied?: () => void + headerActions?: ReactNode + onAmountChange?: (amount: bigint) => void handleWithdrawSuccess?: () => void onOpenSettings?: () => void isSettingsOpen?: boolean diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts index 3f5d1c517..6bdcfa6ae 100644 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts +++ b/src/components/pages/vaults/components/widget/withdraw/useWithdrawFlow.ts @@ -1,7 +1,10 @@ import { useDirectUnstake } from '@pages/vaults/hooks/actions/useDirectUnstake' import { useDirectWithdraw } from '@pages/vaults/hooks/actions/useDirectWithdraw' import { useEnsoWithdraw } from '@pages/vaults/hooks/actions/useEnsoWithdraw' +import { useYvUsdLockedZapWithdraw } from '@pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw' import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' +import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { toAddress } from '@shared/utils' import { useMemo } from 'react' import type { Address } from 'viem' import type { WithdrawalSource, WithdrawRouteType } from './types' @@ -14,19 +17,22 @@ 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 + optimisticApprovedShares?: bigint | null // Account & chain account?: Address chainId: number destinationChainId: number outputChainId: number - // Decimals - assetDecimals: number vaultDecimals: number outputDecimals: number // Price per share @@ -42,24 +48,30 @@ interface UseWithdrawFlowProps { export interface WithdrawFlowResult { routeType: WithdrawRouteType activeFlow: UseWidgetWithdrawFlowReturn + directWithdrawFlow: UseWidgetWithdrawFlowReturn + directUnstakeFlow: UseWidgetWithdrawFlowReturn } -export const useWithdrawFlow = ({ +export function useWithdrawFlow({ withdrawToken, assetAddress, vaultAddress, sourceToken, stakingAddress, + stakingSource, amount, currentAmount, requiredShares, maxShares, + redeemSharesOverride, isMaxWithdraw, + unstakeMaxRedeemShares, + allowDirectWithdrawStep = true, + optimisticApprovedShares, account, chainId, destinationChainId, outputChainId, - assetDecimals, vaultDecimals, outputDecimals, pricePerShare, @@ -68,9 +80,9 @@ export const useWithdrawFlow = ({ isUnstake, isDebouncing, useErc4626 -}: UseWithdrawFlowProps): WithdrawFlowResult => { - // Determine routing type +}: UseWithdrawFlowProps): WithdrawFlowResult { const routeType = useWithdrawRoute({ + vaultAddress, withdrawToken, assetAddress, withdrawalSource, @@ -78,33 +90,63 @@ export const useWithdrawFlow = ({ outputChainId, isUnstake }) + const isDirectWithdrawRoute = routeType === 'DIRECT_WITHDRAW' + const isDirectUnstakeRoute = routeType === 'DIRECT_UNSTAKE' + const isDirectUnstakeWithdrawRoute = routeType === 'DIRECT_UNSTAKE_WITHDRAW' + const isEnsoRoute = routeType === 'ENSO' + + const isYvUsdLockedZapFlow = useMemo( + () => + isDirectWithdrawRoute && + withdrawalSource === 'vault' && + chainId === outputChainId && + toAddress(vaultAddress) === YVUSD_LOCKED_ADDRESS && + toAddress(assetAddress) !== YVUSD_UNLOCKED_ADDRESS && + toAddress(withdrawToken) === toAddress(assetAddress), + [isDirectWithdrawRoute, withdrawalSource, chainId, outputChainId, vaultAddress, assetAddress, withdrawToken] + ) + const directWithdrawEnabled = + allowDirectWithdrawStep && + (isDirectWithdrawRoute || isDirectUnstakeWithdrawRoute) && + amount > 0n && + !isYvUsdLockedZapFlow + const directUnstakeEnabled = (isDirectUnstakeRoute || isDirectUnstakeWithdrawRoute) && currentAmount > 0n + const ensoEnabled = isEnsoRoute && !!withdrawToken && !isDebouncing && requiredShares > 0n && currentAmount > 0n - // 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: directWithdrawEnabled, useErc4626 }) - // Direct unstake flow (staking → vault) + const yvUsdLockedZapWithdraw = useYvUsdLockedZapWithdraw({ + amount, + requiredShares, + optimisticApprovedShares, + account, + chainId, + enabled: isYvUsdLockedZapFlow && amount > 0n + }) + const directUnstake = useDirectUnstake({ stakingAddress, + stakingSource, amount: requiredShares, + redeemAll: isMaxWithdraw, + maxRedeemShares: unstakeMaxRedeemShares, account, chainId, - enabled: routeType === 'DIRECT_UNSTAKE' && currentAmount > 0n + enabled: directUnstakeEnabled }) - // Enso flow (zaps, cross-chain, etc.) const ensoFlow = useEnsoWithdraw({ vaultAddress: sourceToken, withdrawToken, @@ -115,19 +157,54 @@ export const useWithdrawFlow = ({ chainId, destinationChainId, decimalsOut: outputDecimals, - enabled: routeType === 'ENSO' && !!withdrawToken && !isDebouncing && requiredShares > 0n && currentAmount > 0n, + enabled: ensoEnabled, slippage: slippage * 100 }) - // Select active flow based on routing type const activeFlow = useMemo((): UseWidgetWithdrawFlowReturn => { - if (routeType === 'DIRECT_WITHDRAW') return directWithdraw - if (routeType === 'DIRECT_UNSTAKE') return directUnstake + if (isDirectWithdrawRoute) { + return isYvUsdLockedZapFlow ? yvUsdLockedZapWithdraw : directWithdraw + } + if (isDirectUnstakeRoute) { + return directUnstake + } + if (isDirectUnstakeWithdrawRoute) { + 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]) + }, [ + isDirectWithdrawRoute, + isDirectUnstakeRoute, + isDirectUnstakeWithdrawRoute, + isYvUsdLockedZapFlow, + yvUsdLockedZapWithdraw, + 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.test.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts new file mode 100644 index 000000000..48e27065d --- /dev/null +++ b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.test.ts @@ -0,0 +1,64 @@ +import type { Address } from 'viem' +import { describe, expect, it } from 'vitest' +import { resolveWithdrawRouteType } from './useWithdrawRoute' + +const ASSET = '0x0000000000000000000000000000000000000001' as Address +const OTHER = '0x0000000000000000000000000000000000000002' as Address + +describe('resolveWithdrawRouteType', () => { + it('returns DIRECT_UNSTAKE when withdrawing as unstake flow', () => { + const route = resolveWithdrawRouteType({ + withdrawToken: ASSET, + assetAddress: ASSET, + withdrawalSource: 'staking', + chainId: 1, + outputChainId: 1, + isUnstake: true, + ensoEnabled: false + }) + + expect(route).toBe('DIRECT_UNSTAKE') + }) + + it('returns DIRECT_WITHDRAW for same-asset vault withdrawals on same chain', () => { + const route = resolveWithdrawRouteType({ + withdrawToken: ASSET, + assetAddress: ASSET, + withdrawalSource: 'vault', + chainId: 1, + outputChainId: 1, + isUnstake: false, + ensoEnabled: true + }) + + expect(route).toBe('DIRECT_WITHDRAW') + }) + + it('returns ENSO for non-direct routes when Enso is enabled', () => { + const route = resolveWithdrawRouteType({ + withdrawToken: OTHER, + assetAddress: ASSET, + withdrawalSource: 'vault', + chainId: 1, + outputChainId: 1, + isUnstake: false, + ensoEnabled: true + }) + + expect(route).toBe('ENSO') + }) + + it('returns DIRECT_WITHDRAW for non-direct routes when Enso is disabled', () => { + const route = resolveWithdrawRouteType({ + withdrawToken: OTHER, + assetAddress: ASSET, + withdrawalSource: 'vault', + chainId: 1, + outputChainId: 10, + isUnstake: false, + ensoEnabled: false + }) + + expect(route).toBe('DIRECT_WITHDRAW') + }) +}) diff --git a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts index 91acdf002..00d43ce83 100644 --- a/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts +++ b/src/components/pages/vaults/components/widget/withdraw/useWithdrawRoute.ts @@ -5,6 +5,7 @@ import type { Address } from 'viem' import type { WithdrawalSource, WithdrawRouteType } from './types' interface UseWithdrawRouteProps { + vaultAddress: Address withdrawToken: Address assetAddress: Address withdrawalSource: WithdrawalSource @@ -13,13 +14,60 @@ interface UseWithdrawRouteProps { isUnstake: boolean } +interface ResolveWithdrawRouteTypeProps extends Omit { + ensoEnabled: boolean + chainId: number +} + +export const resolveWithdrawRouteType = ({ + withdrawToken, + assetAddress, + withdrawalSource, + chainId, + outputChainId, + isUnstake, + ensoEnabled +}: ResolveWithdrawRouteTypeProps): WithdrawRouteType => { + // Case 1: Unstake (staking → vault tokens) - always allowed, doesn't need Enso + if (isUnstake) { + 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 3: Direct withdraw (vault → asset, same token, from vault source) + if ( + toAddress(withdrawToken) === toAddress(assetAddress) && + withdrawalSource === 'vault' && + chainId === outputChainId + ) { + return 'DIRECT_WITHDRAW' + } + + // Case 4: Everything else uses Enso + return 'ENSO' +} + /** * 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 = ({ + vaultAddress, withdrawToken, assetAddress, withdrawalSource, @@ -27,29 +75,17 @@ export const useWithdrawRoute = ({ outputChainId, isUnstake }: UseWithdrawRouteProps): WithdrawRouteType => { - const ensoEnabled = useEnsoEnabled() + const ensoEnabled = useEnsoEnabled({ chainId, vaultAddress }) return useMemo(() => { - // Case 1: Unstake (staking → vault tokens) - always allowed, doesn't need Enso - if (isUnstake) { - return 'DIRECT_UNSTAKE' - } - - // When Enso disabled, always use direct withdraw - if (!ensoEnabled) { - return 'DIRECT_WITHDRAW' - } - - // Case 2: Direct withdraw (vault → asset, same token, from vault source) - if ( - toAddress(withdrawToken) === toAddress(assetAddress) && - withdrawalSource === 'vault' && - chainId === outputChainId - ) { - return 'DIRECT_WITHDRAW' - } - - // Case 3: Everything else uses Enso - return 'ENSO' + return resolveWithdrawRouteType({ + withdrawToken, + assetAddress, + withdrawalSource, + chainId, + outputChainId, + isUnstake, + ensoEnabled + }) }, [ensoEnabled, isUnstake, withdrawToken, chainId, outputChainId, assetAddress, withdrawalSource]) } 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/components/widget/yvUSD/YvUsdDeposit.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.tsx new file mode 100644 index 000000000..2ab61a119 --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdDeposit.tsx @@ -0,0 +1,275 @@ +import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' +import { + convertYvUsdVariantAmountString, + type TYvUsdVariant, + YVUSD_LOCKED_ADDRESS, + YVUSD_LOCKED_COOLDOWN_DAYS, + YVUSD_UNLOCKED_ADDRESS, + YVUSD_WITHDRAW_WINDOW_DAYS +} from '@pages/vaults/utils/yvUsd' +import { Button } from '@shared/components/Button' +import { IconLock } from '@shared/icons/IconLock' +import { IconLockOpen } from '@shared/icons/IconLockOpen' +import type { TToken } from '@shared/types' +import { toAddress, zeroNormalizedBN } from '@shared/utils' +import type { ReactElement } from 'react' +import { useState } from 'react' +import { useAccount } from 'wagmi' +import { WidgetDeposit } from '../deposit' +import { YvUsdVariantToggle } from './YvUsdVariantToggle' + +type Props = { + chainId: number + assetAddress: `0x${string}` + onDepositSuccess?: () => void + collapseDetails?: boolean + onVariantChange?: (variant: TYvUsdVariant) => void +} + +type DepositPrefill = { + address: `0x${string}` + chainId: number + amount?: string +} + +type LockedDepositExtraTokenCandidate = { + address?: string + name?: string + symbol?: string + decimals?: number + chainID?: number + balance?: TToken['balance'] +} + +type TYvUsdAmountUnit = 'underlying' | 'shares' | 'other' + +function getLockedDepositExtraTokens(token?: LockedDepositExtraTokenCandidate): TToken[] { + if (!token?.address || !token.chainID || !token.symbol || !token.name || !token.decimals) { + return [] + } + + return [ + { + address: toAddress(token.address), + name: token.name, + symbol: token.symbol, + decimals: token.decimals, + chainID: token.chainID, + value: 0, + balance: token.balance ?? zeroNormalizedBN + } + ] +} + +function getYvUsdAmountUnit(address: `0x${string}`, underlyingAssetAddress: `0x${string}`): TYvUsdAmountUnit { + if (address === YVUSD_UNLOCKED_ADDRESS) { + return 'shares' + } + if (address === underlyingAssetAddress) { + return 'underlying' + } + return 'other' +} + +function getDepositPrefill( + variant: TYvUsdVariant | null, + unlockedAssetAddress: `0x${string}`, + chainId: number, + pendingPrefillAmount?: string +): DepositPrefill | undefined { + if (variant === 'locked') { + return { + address: unlockedAssetAddress, + chainId, + amount: pendingPrefillAmount + } + } + if (variant === 'unlocked' && pendingPrefillAmount !== undefined) { + return { + address: unlockedAssetAddress, + chainId, + amount: pendingPrefillAmount + } + } + return undefined +} + +function getYvUsdDepositSymbol(variant: TYvUsdVariant | null): string { + switch (variant) { + case 'locked': + return 'yvUSD (Locked)' + case 'unlocked': + return 'yvUSD (Unlocked)' + default: + return 'yvUSD' + } +} + +function getVaultSharesLabel(variant: TYvUsdVariant | null): string | undefined { + switch (variant) { + case 'locked': + return 'Locked Vault Shares' + case 'unlocked': + return 'Unlocked Vault Shares' + default: + return undefined + } +} + +export function YvUsdDeposit({ + chainId, + assetAddress, + onDepositSuccess, + collapseDetails, + onVariantChange +}: Props): ReactElement { + const { address: account } = useAccount() + const { unlockedVault, lockedVault, metrics, isLoading } = useYvUsdVaults() + const [variant, setVariant] = useState(null) + const [draftDepositAmount, setDraftDepositAmount] = useState('') + const [pendingPrefillAmount, setPendingPrefillAmount] = useState(undefined) + const unlockedAssetAddress = toAddress(unlockedVault?.token.address ?? assetAddress) + const lockedAssetAddress = YVUSD_UNLOCKED_ADDRESS + const [selectedDepositTokenAddress, setSelectedDepositTokenAddress] = useState<`0x${string}` | undefined>(undefined) + const unlockedUserData = useVaultUserData({ + vaultAddress: unlockedVault?.address ?? YVUSD_UNLOCKED_ADDRESS, + assetAddress: unlockedAssetAddress, + chainId, + account + }) + const lockedUserData = useVaultUserData({ + vaultAddress: lockedVault?.address ?? YVUSD_LOCKED_ADDRESS, + assetAddress: lockedAssetAddress, + chainId, + account + }) + const isLockedVariant = variant === 'locked' + const selectedAssetAddress = isLockedVariant ? lockedAssetAddress : unlockedAssetAddress + const selectedVaultUserData = isLockedVariant ? lockedUserData : unlockedUserData + const lockedDepositInputToken = isLockedVariant + ? { + address: unlockedUserData.assetToken?.address ?? unlockedAssetAddress, + name: unlockedUserData.assetToken?.name ?? unlockedVault.token.name ?? 'USD Coin', + symbol: unlockedUserData.assetToken?.symbol ?? unlockedVault.token.symbol ?? 'USDC', + decimals: unlockedUserData.assetToken?.decimals ?? unlockedVault.token.decimals ?? 6, + chainID: unlockedUserData.assetToken?.chainID ?? chainId, + balance: unlockedUserData.assetToken?.balance + } + : undefined + const lockedDepositExtraTokens = getLockedDepositExtraTokens(lockedDepositInputToken) + + if (isLoading || !unlockedVault || !lockedVault) { + return ( +
+
+
+ ) + } + + const unlockedApr = metrics?.unlocked.apy ?? unlockedVault.apr?.netAPR ?? 0 + const lockedApr = metrics?.locked.apy ?? lockedVault.apr?.netAPR ?? 0 + const selectedVault = isLockedVariant ? lockedVault : unlockedVault + const unlockedAssetDecimals = unlockedUserData.assetToken?.decimals ?? 6 + const lockedAssetDecimals = lockedUserData.assetToken?.decimals ?? unlockedUserData.vaultToken?.decimals ?? 18 + const unlockedVaultDecimals = unlockedUserData.vaultToken?.decimals ?? unlockedVault.decimals ?? 18 + + const handleVariantChange = (nextVariant: TYvUsdVariant): void => { + const currentInputTokenAddress = toAddress(selectedDepositTokenAddress ?? unlockedAssetAddress) + const nextInputTokenAddress = unlockedAssetAddress + const currentAmountUnit = getYvUsdAmountUnit(currentInputTokenAddress, unlockedAssetAddress) + const nextAmountUnit = getYvUsdAmountUnit(nextInputTokenAddress, unlockedAssetAddress) + const canPreserveRawAmount = + currentInputTokenAddress === nextInputTokenAddress || + (currentAmountUnit !== 'other' && nextAmountUnit !== 'other') + const shouldConvertAmount = + draftDepositAmount.length > 0 && canPreserveRawAmount && currentAmountUnit !== nextAmountUnit + const nextAmount = shouldConvertAmount + ? convertYvUsdVariantAmountString({ + amount: draftDepositAmount, + fromVariant: currentAmountUnit === 'shares' ? 'locked' : 'unlocked', + toVariant: nextAmountUnit === 'shares' ? 'locked' : 'unlocked', + fromDecimals: currentAmountUnit === 'shares' ? lockedAssetDecimals : unlockedAssetDecimals, + toDecimals: nextAmountUnit === 'shares' ? lockedAssetDecimals : unlockedAssetDecimals, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + : canPreserveRawAmount && draftDepositAmount.length > 0 + ? draftDepositAmount + : undefined + setDraftDepositAmount(nextAmount ?? '') + setPendingPrefillAmount(nextAmount) + setVariant(nextVariant) + onVariantChange?.(nextVariant) + } + const depositPrefill = getDepositPrefill(variant, unlockedAssetAddress, chainId, pendingPrefillAmount) + + const headerToggle = + variant === null ? undefined : + + const depositTypeSection = variant ? ( +
+

+ {variant === 'locked' + ? `Locked deposits earn additional yield from unlocked positions. Your position will be locked with a ${YVUSD_LOCKED_COOLDOWN_DAYS}-day cooldown and a ${YVUSD_WITHDRAW_WINDOW_DAYS} day withdrawal window.` + : `Unlocked deposits stay liquid but earn less.`} +

+
+ ) : ( +
+

{'You can lock your vault position to earn additional yield. Locking helps manage system liquidity.'}

+

{`Locks are subject to a ${YVUSD_LOCKED_COOLDOWN_DAYS}-day cooldown and a ${YVUSD_WITHDRAW_WINDOW_DAYS} day withdrawal window.`}

+

{'Please choose your deposit type'}

+
+ + +
+
+ ) + + return ( +
+ setPendingPrefillAmount(undefined)} + tokenSelectorExtraTokens={lockedDepositExtraTokens} + vaultSharesLabel={getVaultSharesLabel(variant)} + /> +
+ ) +} diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx new file mode 100644 index 000000000..d3b6e5015 --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdVariantToggle.tsx @@ -0,0 +1,41 @@ +import { IconLock } from '@shared/icons/IconLock' +import { IconLockOpen } from '@shared/icons/IconLockOpen' +import { cl, SELECTOR_BAR_STYLES } from '@shared/utils' +import type { ReactElement } from 'react' + +type TYvUsdVariant = 'locked' | 'unlocked' + +type Props = { + activeVariant: TYvUsdVariant + onChange: (variant: TYvUsdVariant) => void +} + +const VARIANT_OPTIONS: { id: TYvUsdVariant; label: string; icon: ReactElement }[] = [ + { id: 'unlocked', label: 'Unlocked', icon: }, + { id: 'locked', label: 'Locked', icon: } +] + +export function YvUsdVariantToggle({ activeVariant, onChange }: Props): ReactElement { + return ( +
+ {VARIANT_OPTIONS.map((option) => ( + + ))} +
+ ) +} diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWidget.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWidget.tsx new file mode 100644 index 000000000..e3532a84f --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWidget.tsx @@ -0,0 +1,124 @@ +import type { TKongVaultView } from '@pages/vaults/domain/kongVaultSelectors' +import { WidgetActionType as ActionType } from '@pages/vaults/types' +import type { TYvUsdVariant } from '@pages/vaults/utils/yvUsd' +import { cl } from '@shared/utils' +import type { ReactElement, ReactNode } from 'react' +import { useState } from 'react' +import { YvUsdDeposit } from './YvUsdDeposit' +import { YvUsdWithdraw } from './YvUsdWithdraw' + +interface Props { + currentVault: TKongVaultView + chainId: number + handleSuccess?: () => void + mode?: ActionType + onModeChange?: (mode: ActionType) => void + showTabs?: boolean + collapseDetails?: boolean + onDepositVariantChange?: (variant: TYvUsdVariant) => void +} + +export function YvUsdWidget({ + currentVault, + chainId, + handleSuccess, + mode: controlledMode, + onModeChange, + showTabs = true, + collapseDetails, + onDepositVariantChange +}: Props): ReactElement { + const [internalMode, setInternalMode] = useState(ActionType.Deposit) + const mode = controlledMode ?? internalMode + const setMode = onModeChange ?? setInternalMode + const selectedComponent = renderSelectedComponent({ + mode, + chainId, + assetAddress: currentVault.token.address, + handleSuccess, + collapseDetails, + onDepositVariantChange + }) + + return ( +
+
+ {showTabs ? ( +
+ {[ActionType.Deposit, ActionType.Withdraw].map((action) => ( + setMode(action)}> + {action === ActionType.Deposit ? 'Deposit' : 'Withdraw'} + + ))} +
+ ) : null} + {selectedComponent} +
+
+ ) +} + +type RenderSelectedComponentParams = { + mode: ActionType + chainId: number + assetAddress: `0x${string}` + handleSuccess?: () => void + collapseDetails?: boolean + onDepositVariantChange?: (variant: TYvUsdVariant) => void +} + +function renderSelectedComponent({ + mode, + chainId, + assetAddress, + handleSuccess, + collapseDetails, + onDepositVariantChange +}: RenderSelectedComponentParams): ReactElement | null { + switch (mode) { + case ActionType.Deposit: + return ( + + ) + case ActionType.Withdraw: + return ( + + ) + default: + return null + } +} + +type TabButtonProps = { + children: ReactNode + onClick: () => void + isActive: boolean +} + +function TabButton({ children, onClick, isActive }: TabButtonProps): ReactElement { + return ( + + ) +} diff --git a/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx new file mode 100644 index 000000000..4a8b2ee93 --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvUSD/YvUsdWithdraw.tsx @@ -0,0 +1,1021 @@ +import { useVaultUserData } from '@pages/vaults/hooks/useVaultUserData' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' +import { + convertYvUsdLockedAssetRawAmountToUnderlying, + convertYvUsdLockedPricePerShareToUnderlying, + convertYvUsdUnderlyingRawAmountToLockedAsset, + getYvUsdLockedWithdrawDisplayMode, + type TYvUsdVariant, + YVUSD_LOCKED_ADDRESS, + YVUSD_LOCKED_COOLDOWN_DAYS, + YVUSD_UNLOCKED_ADDRESS, + YVUSD_WITHDRAW_WINDOW_DAYS +} from '@pages/vaults/utils/yvUsd' +import { Button } from '@shared/components/Button' +import { yvUsdLockedVaultAbi } from '@shared/contracts/abi/yvUsdLockedVault.abi' +import { IconCheck } from '@shared/icons/IconCheck' +import { formatTAmount, toAddress } from '@shared/utils' +import type { ReactElement } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { formatUnits } from 'viem' +import type { UseSimulateContractReturnType } from 'wagmi' +import { useAccount, useReadContract, useSimulateContract } from 'wagmi' +import { InfoOverlay } from '../shared/InfoOverlay' +import { TransactionOverlay, type TransactionStep } from '../shared/TransactionOverlay' +import { WidgetWithdraw } from '../withdraw' +import { formatDays, formatDuration, parseCooldownStatus, resolveDurationSeconds } from './cooldownUtils' +import { YvUsdVariantToggle } from './YvUsdVariantToggle' + +type Props = { + chainId: number + assetAddress: `0x${string}` + onWithdrawSuccess?: () => void + collapseDetails?: boolean +} + +type WithdrawPrefill = { + address: `0x${string}` + chainId: number + amount?: string +} + +type LockedActionDisabledReasonParams = { + isLockedVariant: boolean + account?: `0x${string}` + isCooldownDataLoading: boolean + canWithdrawNow: boolean + hasLocked: boolean + needsCooldownStart: boolean + isCooldownActive: boolean + cooldownRemainingSeconds: number + isWithdrawalWindowOpen: boolean +} + +type TYvUsdAmountUnit = 'underlying' | 'shares' | 'other' + +function getDefaultVariant(hasLocked: boolean, hasUnlocked: boolean): TYvUsdVariant { + if (hasLocked && !hasUnlocked) { + return 'locked' + } + return 'unlocked' +} + +function getWithdrawPrefill( + activeVariant: TYvUsdVariant, + lockedInputAddress: `0x${string}`, + lockedPrefillAddress: `0x${string}` | undefined, + unlockedAssetAddress: `0x${string}`, + chainId: number, + pendingPrefillAmount?: string +): WithdrawPrefill | undefined { + if (activeVariant === 'locked') { + return { + address: lockedPrefillAddress ?? lockedInputAddress, + chainId, + amount: pendingPrefillAmount + } + } + if (pendingPrefillAmount !== undefined) { + return { + address: unlockedAssetAddress, + chainId, + amount: pendingPrefillAmount + } + } + return undefined +} + +function getCooldownRemainingLabel(isCooldownActive: boolean, needsCooldownStart: boolean, seconds: number): string { + if (isCooldownActive) { + return formatDuration(seconds) + } + if (needsCooldownStart) { + return 'Not started' + } + return 'Complete' +} + +function getWithdrawalWindowRemainingLabel( + isWithdrawalWindowOpen: boolean, + isCooldownActive: boolean, + hasActiveCooldown: boolean, + seconds: number +): string { + if (isWithdrawalWindowOpen) { + return formatDuration(seconds) + } + if (isCooldownActive) { + return 'Not open yet' + } + if (hasActiveCooldown) { + return 'Closed' + } + return 'Not started' +} + +function getLockedActionDisabledReason({ + isLockedVariant, + account, + isCooldownDataLoading, + canWithdrawNow, + hasLocked, + needsCooldownStart, + isCooldownActive, + cooldownRemainingSeconds, + isWithdrawalWindowOpen +}: LockedActionDisabledReasonParams): string | undefined { + if (!isLockedVariant || !account) { + return undefined + } + if (isCooldownDataLoading) { + return 'Loading cooldown status...' + } + if (canWithdrawNow || needsCooldownStart) { + return undefined + } + if (!hasLocked) { + return 'No locked yvUSD shares available to withdraw.' + } + if (isCooldownActive) { + return `Cooldown active. Withdrawals open in ${formatDuration(cooldownRemainingSeconds)}.` + } + if (!isWithdrawalWindowOpen) { + return 'Withdrawal window closed. Start a new cooldown to withdraw.' + } + return undefined +} + +function getYvUsdWithdrawSymbol(variant: TYvUsdVariant): string { + return variant === 'locked' ? 'yvUSD (Locked)' : 'yvUSD (Unlocked)' +} + +function getYvUsdAmountUnit(address: `0x${string}`, underlyingAssetAddress: `0x${string}`): TYvUsdAmountUnit { + if (address === YVUSD_UNLOCKED_ADDRESS) { + return 'shares' + } + if (address === underlyingAssetAddress) { + return 'underlying' + } + return 'other' +} + +function convertYvUsdInputAmount({ + amount, + fromUnit, + toUnit, + unlockedPricePerShare, + unlockedVaultDecimals +}: { + amount: bigint + fromUnit: TYvUsdAmountUnit + toUnit: TYvUsdAmountUnit + unlockedPricePerShare: bigint + unlockedVaultDecimals: number +}): bigint { + if (amount <= 0n || fromUnit === toUnit || fromUnit === 'other' || toUnit === 'other') { + return amount + } + + if (fromUnit === 'shares' && toUnit === 'underlying') { + return convertYvUsdLockedAssetRawAmountToUnderlying({ + amount, + unlockedPricePerShare, + unlockedVaultDecimals + }) + } + + if (fromUnit === 'underlying' && toUnit === 'shares') { + return convertYvUsdUnderlyingRawAmountToLockedAsset({ + amount, + unlockedPricePerShare, + unlockedVaultDecimals + }) + } + + return amount +} + +function getAvailableWithdrawSharesCap(sharesUnderCooldown: bigint, sharesCapFromAssets: bigint): bigint { + if (sharesUnderCooldown > 0n && sharesCapFromAssets > 0n) { + return sharesCapFromAssets < sharesUnderCooldown ? sharesCapFromAssets : sharesUnderCooldown + } + if (sharesUnderCooldown > 0n) { + return sharesUnderCooldown + } + return sharesCapFromAssets +} + +function clampLockedRequestedShares( + requestedShares: bigint, + canWithdrawNow: boolean, + availableWithdrawSharesCap: bigint +): bigint { + if (!canWithdrawNow || availableWithdrawSharesCap <= 0n || requestedShares <= availableWithdrawSharesCap) { + return requestedShares + } + return availableWithdrawSharesCap +} + +function resolveLockedRequestedAmountFromInput({ + amount, + inputUnit, + canWithdrawNow, + lockedDisplayPricePerShare, + lockedVaultTokenDecimals, + unlockedPricePerShare, + unlockedVaultDecimals +}: { + amount: bigint + inputUnit: TYvUsdAmountUnit + canWithdrawNow: boolean + lockedDisplayPricePerShare: bigint + lockedVaultTokenDecimals: number + unlockedPricePerShare: bigint + unlockedVaultDecimals: number +}): bigint { + if (inputUnit === 'shares') { + return amount + } + + if (canWithdrawNow) { + if (lockedDisplayPricePerShare <= 0n) { + return 0n + } + + return ( + (amount * 10n ** BigInt(lockedVaultTokenDecimals) + lockedDisplayPricePerShare - 1n) / lockedDisplayPricePerShare + ) + } + + return convertYvUsdUnderlyingRawAmountToLockedAsset({ + amount, + unlockedPricePerShare, + unlockedVaultDecimals + }) +} + +export function YvUsdWithdraw({ chainId, assetAddress, onWithdrawSuccess, collapseDetails }: Props): ReactElement { + const { address: account } = useAccount() + const { unlockedVault, lockedVault, assetAddress: yvUsdAssetAddress, isLoading } = useYvUsdVaults() + const [variant, setVariant] = useState(null) + const [showCooldownOverlay, setShowCooldownOverlay] = useState(false) + const [showCancelCooldownOverlay, setShowCancelCooldownOverlay] = useState(false) + const [showCooldownInfoOverlay, setShowCooldownInfoOverlay] = useState(false) + const [draftWithdrawAmount, setDraftWithdrawAmount] = useState(0n) + const [pendingPrefillAmount, setPendingPrefillAmount] = useState(undefined) + const [pendingPrefillAddress, setPendingPrefillAddress] = useState<`0x${string}` | undefined>(undefined) + const [pendingPrefillShares, setPendingPrefillShares] = useState(undefined) + const [prefillRequestKey, setPrefillRequestKey] = useState(0) + const [lockedRequestedAmountRaw, setLockedRequestedAmountRaw] = useState(0n) + const [selectedWithdrawTokenAddress, setSelectedWithdrawTokenAddress] = useState<`0x${string}` | undefined>(undefined) + const [nowTimestamp, setNowTimestamp] = useState(() => Math.floor(Date.now() / 1000)) + const activeVariant = variant ?? 'unlocked' + const isLockedVariant = activeVariant === 'locked' + + const unlockedAssetAddress = toAddress(yvUsdAssetAddress ?? unlockedVault?.token.address ?? assetAddress) + const lockedAssetAddress = YVUSD_UNLOCKED_ADDRESS + const lockedWithdrawDisplayMode = getYvUsdLockedWithdrawDisplayMode() + const lockedInputAddress = lockedWithdrawDisplayMode === 'underlying' ? unlockedAssetAddress : lockedAssetAddress + + const unlockedUserData = useVaultUserData({ + vaultAddress: unlockedVault?.address ?? YVUSD_UNLOCKED_ADDRESS, + assetAddress: unlockedAssetAddress, + chainId, + account + }) + const lockedUserData = useVaultUserData({ + vaultAddress: lockedVault?.address ?? YVUSD_LOCKED_ADDRESS, + assetAddress: lockedAssetAddress, + chainId, + account + }) + + const lockedWalletShares = lockedUserData.vaultToken?.balance.raw ?? 0n + const lockedAssetDecimals = lockedUserData.assetToken?.decimals ?? 18 + const unlockedAssetDecimals = unlockedUserData.assetToken?.decimals ?? 6 + const unlockedVaultDecimals = unlockedUserData.vaultToken?.decimals ?? unlockedVault?.decimals ?? 18 + const isLockedUnderlyingDisplay = lockedWithdrawDisplayMode === 'underlying' + const hasUnlocked = unlockedUserData.depositedShares > 0n + const hasLocked = lockedWalletShares > 0n + + const { data: rawCooldownDuration } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'cooldownDuration', + chainId, + query: { + enabled: isLockedVariant, + refetchInterval: isLockedVariant ? 60_000 : false + } + }) + const { data: rawWithdrawalWindow } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'withdrawalWindow', + chainId, + query: { + enabled: isLockedVariant, + refetchInterval: isLockedVariant ? 60_000 : false + } + }) + const { + data: rawCooldownStatus, + isLoading: isLoadingCooldownStatus, + refetch: refetchCooldownStatus + } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'getCooldownStatus', + args: account ? [toAddress(account)] : undefined, + chainId, + query: { + enabled: !!account && isLockedVariant, + refetchInterval: isLockedVariant ? 30_000 : false + } + }) + const { + data: rawAvailableWithdrawLimit, + isLoading: isLoadingAvailableWithdrawLimit, + refetch: refetchAvailableWithdrawLimit + } = useReadContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'availableWithdrawLimit', + args: account ? [toAddress(account)] : undefined, + chainId, + query: { + enabled: !!account && isLockedVariant, + refetchInterval: isLockedVariant ? 30_000 : false + } + }) + + const cooldownStatus = useMemo(() => parseCooldownStatus(rawCooldownStatus), [rawCooldownStatus]) + const cooldownDurationSeconds = resolveDurationSeconds(rawCooldownDuration, YVUSD_LOCKED_COOLDOWN_DAYS) + const withdrawalWindowSeconds = resolveDurationSeconds(rawWithdrawalWindow, YVUSD_WITHDRAW_WINDOW_DAYS) + const cooldownDurationLabel = useMemo( + () => formatDays(cooldownDurationSeconds, YVUSD_LOCKED_COOLDOWN_DAYS), + [cooldownDurationSeconds] + ) + const withdrawalWindowLabel = useMemo( + () => formatDays(withdrawalWindowSeconds, YVUSD_WITHDRAW_WINDOW_DAYS), + [withdrawalWindowSeconds] + ) + const availableWithdrawLimit = typeof rawAvailableWithdrawLimit === 'bigint' ? rawAvailableWithdrawLimit : 0n + + const hasActiveCooldown = cooldownStatus.shares > 0n + const isCooldownActive = hasActiveCooldown && nowTimestamp < cooldownStatus.cooldownEnd + const isWithdrawalWindowOpen = + hasActiveCooldown && nowTimestamp >= cooldownStatus.cooldownEnd && nowTimestamp <= cooldownStatus.windowEnd + const isCooldownWindowExpired = hasActiveCooldown && nowTimestamp > cooldownStatus.windowEnd + const needsCooldownStart = hasLocked && (!hasActiveCooldown || isCooldownWindowExpired) + + const cooldownRemainingSeconds = isCooldownActive ? cooldownStatus.cooldownEnd - nowTimestamp : 0 + const windowRemainingSeconds = isWithdrawalWindowOpen ? cooldownStatus.windowEnd - nowTimestamp : 0 + const sharesUnderCooldown = hasActiveCooldown ? cooldownStatus.shares : 0n + const assetsUnderCooldown = useMemo(() => { + if (sharesUnderCooldown <= 0n || lockedUserData.pricePerShare <= 0n) return 0n + const vaultDecimals = lockedUserData.vaultToken?.decimals ?? 18 + return (sharesUnderCooldown * lockedUserData.pricePerShare) / 10n ** BigInt(vaultDecimals) + }, [sharesUnderCooldown, lockedUserData.pricePerShare, lockedUserData.vaultToken?.decimals]) + const lockedDisplayAssetDecimals = isLockedUnderlyingDisplay ? unlockedAssetDecimals : lockedAssetDecimals + const lockedDisplayAssetSymbol = isLockedUnderlyingDisplay + ? (unlockedUserData.assetToken?.symbol ?? 'USDC') + : (lockedUserData.assetToken?.symbol ?? 'yvUSD') + const lockedDisplayPricePerShare = useMemo( + () => + isLockedUnderlyingDisplay + ? convertYvUsdLockedPricePerShareToUnderlying({ + lockedPricePerShare: lockedUserData.pricePerShare, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + : lockedUserData.pricePerShare, + [isLockedUnderlyingDisplay, lockedUserData.pricePerShare, unlockedUserData.pricePerShare, unlockedVaultDecimals] + ) + const displayAssetsUnderCooldown = useMemo( + () => + isLockedUnderlyingDisplay + ? convertYvUsdLockedAssetRawAmountToUnderlying({ + amount: assetsUnderCooldown, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + : assetsUnderCooldown, + [isLockedUnderlyingDisplay, assetsUnderCooldown, unlockedUserData.pricePerShare, unlockedVaultDecimals] + ) + + const formattedSharesUnderCooldown = formatTAmount({ + value: sharesUnderCooldown, + decimals: lockedUserData.vaultToken?.decimals ?? 18 + }) + const formattedAssetsUnderCooldown = formatTAmount({ + value: displayAssetsUnderCooldown, + decimals: lockedDisplayAssetDecimals + }) + const canWithdrawNow = availableWithdrawLimit > 0n + const hasLockedWithdrawPath = hasLocked || hasActiveCooldown || canWithdrawNow + const isCooldownDataLoading = + isLoadingCooldownStatus || + isLoadingAvailableWithdrawLimit || + (isLockedUnderlyingDisplay && unlockedUserData.isLoading) + const lockedVaultTokenDecimals = lockedUserData.vaultToken?.decimals ?? 18 + const lockedVaultTokenSymbol = lockedUserData.vaultToken?.symbol ?? 'yvUSD (Locked)' + const availableWithdrawSharesCapFromAssets = + availableWithdrawLimit > 0n && lockedUserData.pricePerShare > 0n + ? (availableWithdrawLimit * 10n ** BigInt(lockedVaultTokenDecimals)) / lockedUserData.pricePerShare + : 0n + const availableWithdrawSharesCap = getAvailableWithdrawSharesCap( + sharesUnderCooldown, + availableWithdrawSharesCapFromAssets + ) + const availableWithdrawLimitForInput = useMemo(() => { + if (!canWithdrawNow || availableWithdrawSharesCap <= 0n) { + return 0n + } + if (!isLockedUnderlyingDisplay) { + return availableWithdrawSharesCap + } + if (lockedDisplayPricePerShare <= 0n) { + return 0n + } + return (availableWithdrawSharesCap * lockedDisplayPricePerShare) / 10n ** BigInt(lockedVaultTokenDecimals) + }, [ + canWithdrawNow, + availableWithdrawSharesCap, + isLockedUnderlyingDisplay, + lockedDisplayPricePerShare, + lockedVaultTokenDecimals + ]) + const formattedAvailableWithdrawLimit = formatTAmount({ + value: availableWithdrawLimitForInput, + decimals: lockedDisplayAssetDecimals + }) + + const cooldownSharesToStart = useMemo(() => { + if (!needsCooldownStart || lockedRequestedAmountRaw <= 0n) return 0n + if (lockedUserData.pricePerShare <= 0n) return 0n + + const vaultDecimals = lockedUserData.vaultToken?.decimals ?? 18 + const numerator = lockedRequestedAmountRaw * 10n ** BigInt(vaultDecimals) + const requiredShares = (numerator + lockedUserData.pricePerShare - 1n) / lockedUserData.pricePerShare + return requiredShares > lockedWalletShares ? lockedWalletShares : requiredShares + }, [ + needsCooldownStart, + lockedRequestedAmountRaw, + lockedUserData.pricePerShare, + lockedUserData.vaultToken?.decimals, + lockedWalletShares + ]) + const selectedCooldownAssets = useMemo(() => { + if (cooldownSharesToStart <= 0n || lockedUserData.pricePerShare <= 0n) { + return 0n + } + const vaultDecimals = lockedUserData.vaultToken?.decimals ?? 18 + return (cooldownSharesToStart * lockedUserData.pricePerShare) / 10n ** BigInt(vaultDecimals) + }, [cooldownSharesToStart, lockedUserData.pricePerShare, lockedUserData.vaultToken?.decimals]) + const selectedCooldownDisplayAssets = useMemo( + () => + isLockedUnderlyingDisplay + ? convertYvUsdLockedAssetRawAmountToUnderlying({ + amount: selectedCooldownAssets, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + : selectedCooldownAssets, + [isLockedUnderlyingDisplay, selectedCooldownAssets, unlockedUserData.pricePerShare, unlockedVaultDecimals] + ) + const formattedSelectedCooldownAmount = formatTAmount({ + value: selectedCooldownDisplayAssets, + decimals: lockedDisplayAssetDecimals + }) + const cooldownRemainingLabel = getCooldownRemainingLabel( + isCooldownActive, + needsCooldownStart, + cooldownRemainingSeconds + ) + const withdrawalWindowRemainingLabel = getWithdrawalWindowRemainingLabel( + isWithdrawalWindowOpen, + isCooldownActive, + hasActiveCooldown, + windowRemainingSeconds + ) + + const prepareStartCooldown: UseSimulateContractReturnType = useSimulateContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'startCooldown', + args: cooldownSharesToStart > 0n ? [cooldownSharesToStart] : undefined, + account: account ? toAddress(account) : undefined, + chainId, + query: { + enabled: !!account && isLockedVariant && needsCooldownStart && cooldownSharesToStart > 0n + } + }) + const prepareCancelCooldown: UseSimulateContractReturnType = useSimulateContract({ + address: YVUSD_LOCKED_ADDRESS, + abi: yvUsdLockedVaultAbi, + functionName: 'cancelCooldown', + account: account ? toAddress(account) : undefined, + chainId, + query: { + enabled: !!account && isLockedVariant && hasActiveCooldown + } + }) + + const cooldownStep = useMemo((): TransactionStep | undefined => { + if (!prepareStartCooldown.isSuccess || !prepareStartCooldown.data?.request) return undefined + + const formattedCooldownShares = formatTAmount({ + value: cooldownSharesToStart, + decimals: lockedUserData.vaultToken?.decimals ?? 18 + }) + const cooldownConfirmMessage = isLockedUnderlyingDisplay + ? `Starting cooldown for ${formattedSelectedCooldownAmount} ${lockedDisplayAssetSymbol}` + : `Starting cooldown for ${formattedCooldownShares} locked shares` + + return { + prepare: prepareStartCooldown as unknown as UseSimulateContractReturnType, + label: 'Start Cooldown', + confirmMessage: cooldownConfirmMessage, + successTitle: 'Cooldown started', + successMessage: `Cooldown has started. Withdrawals become available in ${cooldownDurationLabel}.`, + notification: + account && cooldownSharesToStart > 0n + ? { + type: 'start cooldown', + amount: formatTAmount({ value: cooldownSharesToStart, decimals: lockedVaultTokenDecimals }), + fromAddress: YVUSD_LOCKED_ADDRESS, + fromSymbol: lockedVaultTokenSymbol, + fromChainId: chainId + } + : undefined + } + }, [ + account, + chainId, + prepareStartCooldown, + cooldownDurationLabel, + cooldownSharesToStart, + formattedSelectedCooldownAmount, + isLockedUnderlyingDisplay, + lockedDisplayAssetSymbol, + lockedVaultTokenDecimals, + lockedVaultTokenSymbol + ]) + + const handleCooldownSuccess = useCallback((): void => { + setShowCooldownOverlay(false) + void refetchCooldownStatus() + void refetchAvailableWithdrawLimit() + void lockedUserData.refetch() + }, [lockedUserData, refetchCooldownStatus, refetchAvailableWithdrawLimit]) + + const cancelCooldownStep = useMemo((): TransactionStep | undefined => { + if (!prepareCancelCooldown.isSuccess || !prepareCancelCooldown.data?.request) return undefined + + return { + prepare: prepareCancelCooldown as unknown as UseSimulateContractReturnType, + label: 'Cancel Cooldown', + confirmMessage: 'Canceling active cooldown for locked yvUSD shares', + successTitle: 'Cooldown canceled', + successMessage: 'Cooldown canceled. Start a new cooldown to withdraw from the locked vault.', + notification: + account && sharesUnderCooldown > 0n + ? { + type: 'cancel cooldown', + amount: formatTAmount({ value: sharesUnderCooldown, decimals: lockedVaultTokenDecimals }), + fromAddress: YVUSD_LOCKED_ADDRESS, + fromSymbol: lockedVaultTokenSymbol, + fromChainId: chainId + } + : undefined + } + }, [account, chainId, lockedVaultTokenDecimals, lockedVaultTokenSymbol, prepareCancelCooldown, sharesUnderCooldown]) + + const handleCancelCooldownSuccess = useCallback((): void => { + setShowCancelCooldownOverlay(false) + void refetchCooldownStatus() + void refetchAvailableWithdrawLimit() + void lockedUserData.refetch() + }, [lockedUserData, refetchCooldownStatus, refetchAvailableWithdrawLimit]) + + const handleLockedWithdrawSuccess = useCallback((): void => { + void refetchCooldownStatus() + void refetchAvailableWithdrawLimit() + onWithdrawSuccess?.() + }, [onWithdrawSuccess, refetchCooldownStatus, refetchAvailableWithdrawLimit]) + + const lockedActionDisabledReason = useMemo(() => { + return getLockedActionDisabledReason({ + isLockedVariant, + account, + isCooldownDataLoading, + canWithdrawNow, + hasLocked, + needsCooldownStart, + isCooldownActive, + cooldownRemainingSeconds, + isWithdrawalWindowOpen + }) + }, [ + isLockedVariant, + account, + isCooldownDataLoading, + canWithdrawNow, + hasLocked, + needsCooldownStart, + isCooldownActive, + cooldownRemainingSeconds, + isWithdrawalWindowOpen + ]) + + useEffect(() => { + if (variant === null) { + setVariant(getDefaultVariant(hasLockedWithdrawPath, hasUnlocked)) + } + }, [hasLockedWithdrawPath, hasUnlocked, variant]) + + useEffect(() => { + if (!isLockedVariant) return + + setNowTimestamp(Math.floor(Date.now() / 1000)) + const interval = window.setInterval(() => { + setNowTimestamp(Math.floor(Date.now() / 1000)) + }, 1_000) + + return () => window.clearInterval(interval) + }, [isLockedVariant]) + + const lockedDisplayUserData = useMemo(() => { + if (!isLockedUnderlyingDisplay) { + return lockedUserData + } + + return { + ...lockedUserData, + assetToken: unlockedUserData.assetToken, + availableToDeposit: unlockedUserData.availableToDeposit, + depositedValue: convertYvUsdLockedAssetRawAmountToUnderlying({ + amount: lockedUserData.depositedValue, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }), + pricePerShare: lockedDisplayPricePerShare, + isLoading: lockedUserData.isLoading || unlockedUserData.isLoading, + refetch: (): void => { + lockedUserData.refetch() + unlockedUserData.refetch() + } + } + }, [ + isLockedUnderlyingDisplay, + lockedUserData, + unlockedUserData, + unlockedUserData.assetToken, + unlockedUserData.availableToDeposit, + unlockedUserData.pricePerShare, + unlockedVaultDecimals, + lockedDisplayPricePerShare + ]) + + const handleFillAvailableWithdrawAmount = useCallback((): void => { + if (!canWithdrawNow || availableWithdrawLimitForInput <= 0n) { + return + } + setPendingPrefillShares(availableWithdrawSharesCap) + setLockedRequestedAmountRaw(availableWithdrawSharesCap) + setPendingPrefillAddress(selectedWithdrawTokenAddress ?? lockedInputAddress) + setPendingPrefillAmount(formatUnits(availableWithdrawLimitForInput, lockedDisplayAssetDecimals)) + setPrefillRequestKey((current) => current + 1) + }, [ + canWithdrawNow, + availableWithdrawSharesCap, + availableWithdrawLimitForInput, + lockedDisplayAssetDecimals, + selectedWithdrawTokenAddress, + lockedInputAddress + ]) + + const selectedDisplayAssetAddress = isLockedVariant ? lockedInputAddress : unlockedAssetAddress + + const handleVariantChange = useCallback( + (nextVariant: TYvUsdVariant): void => { + const currentInputTokenAddress = toAddress(selectedWithdrawTokenAddress ?? selectedDisplayAssetAddress) + const nextInputTokenAddress = nextVariant === 'locked' ? lockedInputAddress : unlockedAssetAddress + const currentAmountUnit = getYvUsdAmountUnit(currentInputTokenAddress, unlockedAssetAddress) + const nextAmountUnit = getYvUsdAmountUnit(nextInputTokenAddress, unlockedAssetAddress) + const canPreserveRawAmount = + currentInputTokenAddress === nextInputTokenAddress || + (currentAmountUnit !== 'other' && nextAmountUnit !== 'other') + const shouldConvertAmount = + draftWithdrawAmount > 0n && canPreserveRawAmount && currentAmountUnit !== nextAmountUnit + const nextRawAmount = shouldConvertAmount + ? convertYvUsdInputAmount({ + amount: draftWithdrawAmount, + fromUnit: currentAmountUnit, + toUnit: nextAmountUnit, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + : canPreserveRawAmount + ? draftWithdrawAmount + : 0n + const nextInputDecimals = nextAmountUnit === 'shares' ? lockedAssetDecimals : unlockedAssetDecimals + setDraftWithdrawAmount(nextRawAmount) + setPendingPrefillAmount(nextRawAmount > 0n ? formatUnits(nextRawAmount, nextInputDecimals) : undefined) + setPendingPrefillAddress(nextVariant === 'locked' ? lockedInputAddress : unlockedAssetAddress) + setPendingPrefillShares(undefined) + const nextLockedRequestedAmount = + nextVariant === 'locked' + ? resolveLockedRequestedAmountFromInput({ + amount: nextRawAmount, + inputUnit: nextAmountUnit, + canWithdrawNow, + lockedDisplayPricePerShare, + lockedVaultTokenDecimals, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + : 0n + setLockedRequestedAmountRaw( + clampLockedRequestedShares(nextLockedRequestedAmount, canWithdrawNow, availableWithdrawSharesCap) + ) + setVariant(nextVariant) + }, + [ + selectedWithdrawTokenAddress, + selectedDisplayAssetAddress, + lockedInputAddress, + unlockedAssetAddress, + draftWithdrawAmount, + unlockedUserData.pricePerShare, + unlockedVaultDecimals, + lockedAssetDecimals, + unlockedAssetDecimals, + canWithdrawNow, + lockedDisplayPricePerShare, + lockedVaultTokenDecimals, + availableWithdrawSharesCap + ] + ) + + const handleAmountChange = useCallback( + (amount: bigint): void => { + setDraftWithdrawAmount(amount) + if (!isLockedVariant) { + return + } + + if (pendingPrefillShares !== undefined) { + setLockedRequestedAmountRaw(canWithdrawNow ? pendingPrefillShares : 0n) + setPendingPrefillShares(undefined) + return + } + + const inputUnit = isLockedUnderlyingDisplay ? 'underlying' : 'shares' + const nextRequestedAmount = resolveLockedRequestedAmountFromInput({ + amount, + inputUnit, + canWithdrawNow, + lockedDisplayPricePerShare, + lockedVaultTokenDecimals, + unlockedPricePerShare: unlockedUserData.pricePerShare, + unlockedVaultDecimals + }) + setLockedRequestedAmountRaw( + clampLockedRequestedShares(nextRequestedAmount, canWithdrawNow, availableWithdrawSharesCap) + ) + }, + [ + isLockedVariant, + pendingPrefillShares, + canWithdrawNow, + isLockedUnderlyingDisplay, + lockedDisplayPricePerShare, + lockedVaultTokenDecimals, + unlockedUserData.pricePerShare, + unlockedVaultDecimals, + availableWithdrawSharesCap + ] + ) + + if (isLoading || !unlockedVault || !lockedVault) { + return ( +
+
+
+ ) + } + + const selectedVault = isLockedVariant ? lockedVault : unlockedVault + const selectedRouteAssetAddress = + isLockedVariant && isLockedUnderlyingDisplay + ? unlockedAssetAddress + : isLockedVariant + ? lockedAssetAddress + : unlockedAssetAddress + const selectedVaultUserData = isLockedVariant ? lockedDisplayUserData : unlockedUserData + const disableLockedAmountInput = isLockedVariant && isCooldownActive + const hideLockedWithdrawAction = isLockedVariant && !!account && !canWithdrawNow + const effectiveLockedActionDisabledReason = + isLockedVariant && !hideLockedWithdrawAction ? lockedActionDisabledReason : undefined + const lockedRequestedShares = clampLockedRequestedShares( + lockedRequestedAmountRaw, + canWithdrawNow, + availableWithdrawSharesCap + ) + const lockedExpectedUnderlyingOut = + lockedDisplayPricePerShare > 0n + ? (lockedRequestedShares * lockedDisplayPricePerShare) / 10n ** BigInt(lockedVaultTokenDecimals) + : 0n + + const withdrawPrefill = getWithdrawPrefill( + activeVariant, + lockedInputAddress, + pendingPrefillAddress, + unlockedAssetAddress, + chainId, + pendingPrefillAmount + ) + const showStartCooldownActions = + !!account && hasLocked && !isCooldownDataLoading && needsCooldownStart && !canWithdrawNow + const showCancelCooldownAction = + !!account && hasLocked && hasActiveCooldown && !isCooldownDataLoading && !isWithdrawalWindowOpen + const showInlineResetCooldownAction = + !!account && hasLocked && hasActiveCooldown && !isCooldownDataLoading && isWithdrawalWindowOpen + const isStartCooldownPending = prepareStartCooldown.isLoading || prepareStartCooldown.isFetching + const isCancelCooldownPending = prepareCancelCooldown.isLoading || prepareCancelCooldown.isFetching + + let cooldownStatusContent: ReactElement + if (!account) { + cooldownStatusContent =

{'Connect wallet to view cooldown status.'}

+ } else if (!hasLockedWithdrawPath) { + cooldownStatusContent =

{'No locked balance found in this wallet.'}

+ } else if (isCooldownDataLoading) { + cooldownStatusContent =

{'Loading cooldown status...'}

+ } else { + cooldownStatusContent = ( + <> + {canWithdrawNow ? ( +

+ {'Available to withdraw now:'} + +

+ ) : hasActiveCooldown ? ( + isLockedUnderlyingDisplay ? ( +

{`Amount in cooldown: ${formattedAssetsUnderCooldown} ${lockedDisplayAssetSymbol}`}

+ ) : ( + <> +

{`Shares in cooldown: ${formattedSharesUnderCooldown}`}

+

{`Estimated assets in cooldown: ${formattedAssetsUnderCooldown} ${lockedDisplayAssetSymbol}`}

+ + ) + ) : null} + {needsCooldownStart ? ( +

{`Selected cooldown amount: ${formattedSelectedCooldownAmount} ${lockedDisplayAssetSymbol}`}

+ ) : null} +

+ {'Cooldown remaining:'} + {isWithdrawalWindowOpen ? ( + + {'Complete'} + + {showInlineResetCooldownAction ? ( + + ) : null} + + ) : ( + {cooldownRemainingLabel} + )} +

+

{`Withdrawal window remaining: ${withdrawalWindowRemainingLabel}`}

+ + ) + } + + const withdrawTypeSection = isLockedVariant ? ( +
+
+
+

{'Locked withdrawal cooldown'}

+ +
+

+ {`Cooldown: ${cooldownDurationLabel} | Withdrawal window: ${withdrawalWindowLabel}`} +

+
+
{cooldownStatusContent}
+ {showStartCooldownActions || showCancelCooldownAction ? ( +
+ {showStartCooldownActions ? ( + + ) : null} + {showCancelCooldownAction ? ( + + ) : null} +
+ ) : null} +
+ ) : undefined + + return ( +
+ } + onAmountChange={handleAmountChange} + onTokenSelectionChange={setSelectedWithdrawTokenAddress} + handleWithdrawSuccess={isLockedVariant ? handleLockedWithdrawSuccess : onWithdrawSuccess} + hideContainerBorder + contentBelowInput={withdrawTypeSection} + collapseDetails={collapseDetails} + prefill={withdrawPrefill} + prefillRequestKey={`${activeVariant}-${prefillRequestKey}`} + onPrefillApplied={() => { + setPendingPrefillAmount(undefined) + setPendingPrefillAddress(undefined) + }} + /> + setShowCooldownOverlay(false)} + step={cooldownStep} + isLastStep + onAllComplete={handleCooldownSuccess} + /> + setShowCancelCooldownOverlay(false)} + step={cancelCooldownStep} + isLastStep + onAllComplete={handleCancelCooldownSuccess} + /> + setShowCooldownInfoOverlay(false)} + title="Cooldown info" + > +
+

{'Locked yvUSD withdrawals use a cooldown period before the withdrawal window opens.'}

+

{'Interest continues to accrue while your position is in cooldown.'}

+

+ { + 'If only part of your deposited amount is in cooldown and you want to include more funds, cancel the current cooldown and restart it with the larger amount.' + } +

+

{`Cooldown period: ${cooldownDurationLabel}. Withdrawal window: ${withdrawalWindowLabel}.`}

+
+
+
+ ) +} diff --git a/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts b/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts new file mode 100644 index 000000000..b264fb5f7 --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { formatDays, resolveDurationSeconds } from './cooldownUtils' + +describe('resolveDurationSeconds', () => { + it('uses the contract-provided duration when available', () => { + expect(resolveDurationSeconds(7n * 86_400n, 5)).toBe(604800) + }) + + it('falls back to the configured number of days when contract data is unavailable', () => { + expect(resolveDurationSeconds(undefined, 5)).toBe(432000) + }) +}) + +describe('formatDays', () => { + it('formats the 5-day withdrawal fallback label correctly', () => { + expect(formatDays(resolveDurationSeconds(undefined, 5), 5)).toBe('5 days') + }) + + it('formats contract-provided withdrawal windows without forcing the fallback value', () => { + expect(formatDays(resolveDurationSeconds(7n * 86_400n, 5), 5)).toBe('7 days') + }) +}) diff --git a/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.ts b/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.ts new file mode 100644 index 000000000..43676e51d --- /dev/null +++ b/src/components/pages/vaults/components/widget/yvUSD/cooldownUtils.ts @@ -0,0 +1,76 @@ +export type TYvUsdCooldownStatus = { + cooldownEnd: number + windowEnd: number + shares: bigint +} + +export const EMPTY_COOLDOWN_STATUS: TYvUsdCooldownStatus = { + cooldownEnd: 0, + windowEnd: 0, + shares: 0n +} + +function parseCooldownTimestamp(value: unknown): number { + return typeof value === 'bigint' ? Number(value) : 0 +} + +function parseCooldownShares(value: unknown): bigint { + return typeof value === 'bigint' ? value : 0n +} + +export function parseCooldownStatus(status: unknown): TYvUsdCooldownStatus { + if (!status) return EMPTY_COOLDOWN_STATUS + + if (Array.isArray(status)) { + const [cooldownEnd, windowEnd, shares] = status + return { + cooldownEnd: parseCooldownTimestamp(cooldownEnd), + windowEnd: parseCooldownTimestamp(windowEnd), + shares: parseCooldownShares(shares) + } + } + + if (typeof status === 'object' && status !== null) { + const parsed = status as { + cooldownEnd?: unknown + windowEnd?: unknown + shares?: unknown + } + return { + cooldownEnd: parseCooldownTimestamp(parsed.cooldownEnd), + windowEnd: parseCooldownTimestamp(parsed.windowEnd), + shares: parseCooldownShares(parsed.shares) + } + } + + return EMPTY_COOLDOWN_STATUS +} + +export const formatDuration = (seconds: number): string => { + if (!Number.isFinite(seconds) || seconds <= 0) return '0s' + const totalSeconds = Math.floor(seconds) + const days = Math.floor(totalSeconds / 86_400) + const hours = Math.floor((totalSeconds % 86_400) / 3_600) + const minutes = Math.floor((totalSeconds % 3_600) / 60) + const secs = totalSeconds % 60 + + if (days > 0) return `${days}d ${hours}h ${minutes}m ${secs}s` + if (hours > 0) return `${hours}h ${minutes}m ${secs}s` + if (minutes > 0) return `${minutes}m ${secs}s` + return `${secs}s` +} + +export const formatDays = (seconds: number, fallbackDays: number): string => { + if (!Number.isFinite(seconds) || seconds <= 0) return `${fallbackDays} days` + const days = seconds / 86_400 + const rounded = Math.round(days * 100) / 100 + return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded} days` +} + +export const resolveDurationSeconds = (rawDuration: unknown, fallbackDays: number): number => { + if (typeof rawDuration === 'bigint') { + return Number(rawDuration) + } + + return fallbackDays * 86_400 +} diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx new file mode 100644 index 000000000..739b6312b --- /dev/null +++ b/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.test.tsx @@ -0,0 +1,11 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { YvUsdApyDetailsContent } from './YvUsdBreakdown' + +describe('YvUsdApyDetailsContent', () => { + it('describes the locked withdrawal window as 5 days', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('Withdrawals are open for 5 days once the cooldown ends.') + }) +}) diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.tsx new file mode 100644 index 000000000..6ffac5e30 --- /dev/null +++ b/src/components/pages/vaults/components/yvUSD/YvUsdBreakdown.tsx @@ -0,0 +1,145 @@ +import { YVUSD_DESCRIPTION, YVUSD_LOCKED_COOLDOWN_DAYS, YVUSD_WITHDRAW_WINDOW_DAYS } from '@pages/vaults/utils/yvUsd' +import { RenderAmount } from '@shared/components/RenderAmount' +import { IconInfinifiPoints } from '@shared/icons/IconInfinifiPoints' +import { IconLock } from '@shared/icons/IconLock' +import { IconLockOpen } from '@shared/icons/IconLockOpen' +import { cl, formatAmount } from '@shared/utils' +import type { ReactElement } from 'react' + +type TYvUsdTooltipProps = { + lockedValue: number + unlockedValue: number + className?: string + iconClassName?: string + infinifiPointsNote?: string +} + +const YvUsdTooltipRow = ({ + icon, + label, + value, + symbol, + options +}: { + icon: ReactElement + label: string + value: number + symbol: 'percent' | 'USD' + options?: { + maximumFractionDigits?: number + minimumFractionDigits?: number + shouldCompactValue?: boolean + } +}) => { + const decimals = symbol === 'percent' ? 6 : 0 + return ( +
+ + {icon} + {label} + + +
+ ) +} + +export function YvUsdApyTooltipContent({ + lockedValue, + unlockedValue, + className, + iconClassName = 'size-3', + infinifiPointsNote +}: TYvUsdTooltipProps): ReactElement { + return ( +
+
+ } + label="Locked APY" + value={lockedValue} + symbol="percent" + options={{ maximumFractionDigits: 2, minimumFractionDigits: 2 }} + /> + } + label="Unlocked APY" + value={unlockedValue} + symbol="percent" + options={{ maximumFractionDigits: 2, minimumFractionDigits: 2 }} + /> + {infinifiPointsNote ? ( +
+

+ + {infinifiPointsNote} +

+
+ ) : null} +
+
+ ) +} + +export function YvUsdTvlTooltipContent({ + lockedValue, + unlockedValue, + className, + iconClassName = 'size-3' +}: TYvUsdTooltipProps): ReactElement { + return ( +
+
+ } + label="Locked TVL" + value={lockedValue} + symbol="USD" + options={{ shouldCompactValue: true, maximumFractionDigits: 2, minimumFractionDigits: 0 }} + /> + } + label="Unlocked TVL" + value={unlockedValue} + symbol="USD" + options={{ shouldCompactValue: true, maximumFractionDigits: 2, minimumFractionDigits: 0 }} + /> +
+
+ ) +} + +export function YvUsdApyDetailsContent({ + lockedValue, + unlockedValue, + infinifiPointsNote +}: { + lockedValue: number + unlockedValue: number + infinifiPointsNote?: string +}): ReactElement { + const upliftPercent = formatAmount(Math.max(0, (lockedValue - unlockedValue) * 100), 0, 2) + + return ( +
+

{YVUSD_DESCRIPTION}

+
+

{'Current estimates'}

+
+ +
+
+

+ {`Locked deposits currently show about ${upliftPercent}% APY uplift and require a ${YVUSD_LOCKED_COOLDOWN_DAYS}-day cooldown. Withdrawals are open for ${YVUSD_WITHDRAW_WINDOW_DAYS} days once the cooldown ends.`} +

+
+ ) +} diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx new file mode 100644 index 000000000..12bbc1379 --- /dev/null +++ b/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.test.tsx @@ -0,0 +1,16 @@ +import { YVUSD_LEARN_MORE_URL } from '@pages/vaults/utils/yvUsd' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { YvUsdHeaderBanner } from './YvUsdHeaderBanner' + +describe('YvUsdHeaderBanner', () => { + it('renders the mockup copy, links, and shipped banner assets', () => { + const html = renderToStaticMarkup() + + expect(html).toContain('Transparent, Verifiable, Real Yield') + expect(html).toContain(`href="${YVUSD_LEARN_MORE_URL}"`) + expect(html).toContain('yvusd-banner-bg.png') + expect(html).toContain('Learn more') + expect(html).toContain('about Yearn's new cross-chain, cross-asset, delta-neutral vault.') + }) +}) diff --git a/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.tsx b/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.tsx new file mode 100644 index 000000000..3e0fd914d --- /dev/null +++ b/src/components/pages/vaults/components/yvUSD/YvUsdHeaderBanner.tsx @@ -0,0 +1,37 @@ +import { YVUSD_LEARN_MORE_URL } from '@pages/vaults/utils/yvUsd' +import { cl } from '@shared/utils' +import type { CSSProperties, ReactElement } from 'react' + +const BANNER_BACKGROUND_STYLE: CSSProperties = { + backgroundImage: `url(${import.meta.env.BASE_URL || '/'}yvusd-banner-bg.png)`, + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + backgroundSize: 'cover' +} + +export function YvUsdHeaderBanner({ className }: { className?: string }): ReactElement { + return ( +
+ ) +} diff --git a/src/components/pages/vaults/constants/ensoDisabledVaults.ts b/src/components/pages/vaults/constants/ensoDisabledVaults.ts new file mode 100644 index 000000000..20a5539e1 --- /dev/null +++ b/src/components/pages/vaults/constants/ensoDisabledVaults.ts @@ -0,0 +1,21 @@ +import { toAddress } from '@shared/utils' +import type { Address } from 'viem' + +// Add vault addresses here to disable Enso routing on a per-vault basis. +// Example: +// 1: ['0x27B5739e22ad9033bcBf192059122d163b60349D'] //yCRV +const ENSO_DISABLED_VAULTS_BY_CHAIN: Partial> = {} + +export const isVaultEnsoDisabled = (chainId?: number, vaultAddress?: Address): boolean => { + if (!chainId || !vaultAddress) { + return false + } + + const disabledVaults = ENSO_DISABLED_VAULTS_BY_CHAIN[chainId] + if (!disabledVaults || disabledVaults.length === 0) { + return false + } + + const normalizedVaultAddress = toAddress(vaultAddress) + return disabledVaults.some((address) => toAddress(address) === normalizedVaultAddress) +} 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/actions/useYvUsdLockedZapDeposit.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts new file mode 100644 index 000000000..13d924e96 --- /dev/null +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapDeposit.ts @@ -0,0 +1,78 @@ +import type { UseWidgetDepositFlowReturn } from '@pages/vaults/types' +import { YVUSD_LOCKED_ZAP_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { yvUsdLockedZapAbi } from '@shared/contracts/abi/yvUsdLockedZap.abi' +import { toAddress } from '@shared/utils' +import type { Address } from 'viem' +import { erc20Abi } from 'viem' +import type { UseSimulateContractReturnType } from 'wagmi' +import { useReadContract, useSimulateContract } from 'wagmi' +import { useTokenAllowance } from '../useTokenAllowance' + +interface UseYvUsdLockedZapDepositParams { + depositToken: Address + amount: bigint + account?: Address + chainId: number + enabled: boolean +} + +export function useYvUsdLockedZapDeposit(params: UseYvUsdLockedZapDepositParams): UseWidgetDepositFlowReturn { + const { allowance = 0n } = useTokenAllowance({ + account: params.account, + token: params.depositToken, + spender: YVUSD_LOCKED_ZAP_ADDRESS, + watch: true, + chainId: params.chainId + }) + + const isValidInput = params.amount > 0n + const isAllowanceSufficient = allowance >= params.amount + const prepareApproveEnabled = !!params.account && params.enabled && isValidInput && !isAllowanceSufficient + const prepareDepositEnabled = !!params.account && params.enabled && isValidInput && isAllowanceSufficient + + const { data: expectedOut = 0n } = useReadContract({ + address: YVUSD_LOCKED_ZAP_ADDRESS, + abi: yvUsdLockedZapAbi, + functionName: 'previewZapIn', + args: [params.amount], + chainId: params.chainId, + query: { enabled: params.enabled && isValidInput } + }) + + const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + abi: erc20Abi, + functionName: 'approve', + address: params.depositToken, + args: params.amount > 0n ? [YVUSD_LOCKED_ZAP_ADDRESS, params.amount] : undefined, + chainId: params.chainId, + query: { enabled: prepareApproveEnabled } + }) + + const prepareDeposit: UseSimulateContractReturnType = useSimulateContract({ + address: YVUSD_LOCKED_ZAP_ADDRESS, + abi: yvUsdLockedZapAbi, + functionName: 'zapIn', + args: params.account && params.amount > 0n ? [params.amount, toAddress(params.account)] : undefined, + account: params.account ? toAddress(params.account) : undefined, + chainId: params.chainId, + query: { enabled: prepareDepositEnabled } + }) + + return { + actions: { + prepareApprove, + prepareDeposit + }, + periphery: { + prepareApproveEnabled, + prepareDepositEnabled, + isAllowanceSufficient, + allowance, + expectedOut, + isLoadingRoute: false, + isCrossChain: false, + routerAddress: YVUSD_LOCKED_ZAP_ADDRESS, + error: undefined + } + } +} diff --git a/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts new file mode 100644 index 000000000..ef2da7b7c --- /dev/null +++ b/src/components/pages/vaults/hooks/actions/useYvUsdLockedZapWithdraw.ts @@ -0,0 +1,80 @@ +import type { UseWidgetWithdrawFlowReturn } from '@pages/vaults/types' +import { YVUSD_LOCKED_ADDRESS, YVUSD_LOCKED_ZAP_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { yvUsdLockedZapAbi } from '@shared/contracts/abi/yvUsdLockedZap.abi' +import { toAddress } from '@shared/utils' +import type { Address } from 'viem' +import { erc20Abi } from 'viem' +import type { UseSimulateContractReturnType } from 'wagmi' +import { useSimulateContract } from 'wagmi' +import { useTokenAllowance } from '../useTokenAllowance' + +interface UseYvUsdLockedZapWithdrawParams { + amount: bigint + requiredShares: bigint + optimisticApprovedShares?: bigint | null + account?: Address + chainId: number + enabled: boolean +} + +function getEffectiveApprovedShares(allowance: bigint, optimisticApprovedShares?: bigint | null): bigint { + if (optimisticApprovedShares && optimisticApprovedShares > allowance) { + return optimisticApprovedShares + } + + return allowance +} + +export function useYvUsdLockedZapWithdraw(params: UseYvUsdLockedZapWithdrawParams): UseWidgetWithdrawFlowReturn { + const { allowance = 0n } = useTokenAllowance({ + account: params.account, + token: YVUSD_LOCKED_ADDRESS, + spender: YVUSD_LOCKED_ZAP_ADDRESS, + watch: true, + chainId: params.chainId + }) + + const effectiveApprovedShares = getEffectiveApprovedShares(allowance, params.optimisticApprovedShares) + const isAllowanceSufficient = effectiveApprovedShares >= params.requiredShares + const prepareApproveEnabled = + !!params.account && params.enabled && params.amount > 0n && params.requiredShares > 0n && !isAllowanceSufficient + const prepareWithdrawEnabled = + !!params.account && params.enabled && params.amount > 0n && params.requiredShares > 0n && isAllowanceSufficient + + const prepareApprove: UseSimulateContractReturnType = useSimulateContract({ + abi: erc20Abi, + functionName: 'approve', + address: YVUSD_LOCKED_ADDRESS, + args: params.requiredShares > 0n ? [YVUSD_LOCKED_ZAP_ADDRESS, params.requiredShares] : undefined, + chainId: params.chainId, + query: { enabled: prepareApproveEnabled } + }) + + const prepareWithdraw: UseSimulateContractReturnType = useSimulateContract({ + address: YVUSD_LOCKED_ZAP_ADDRESS, + abi: yvUsdLockedZapAbi, + functionName: 'zapOut', + args: params.account && params.requiredShares > 0n ? [params.requiredShares, toAddress(params.account)] : undefined, + account: params.account ? toAddress(params.account) : undefined, + chainId: params.chainId, + query: { enabled: prepareWithdrawEnabled } + }) + + return { + actions: { + prepareApprove, + prepareWithdraw + }, + periphery: { + prepareApproveEnabled, + prepareWithdrawEnabled, + isAllowanceSufficient, + allowance, + expectedOut: params.amount, + isLoadingRoute: false, + isCrossChain: false, + routerAddress: YVUSD_LOCKED_ZAP_ADDRESS, + error: undefined + } + } +} diff --git a/src/components/pages/vaults/hooks/useEnsoEnabled.ts b/src/components/pages/vaults/hooks/useEnsoEnabled.ts index 5cb3a7c94..bdcc7b8ee 100644 --- a/src/components/pages/vaults/hooks/useEnsoEnabled.ts +++ b/src/components/pages/vaults/hooks/useEnsoEnabled.ts @@ -1,8 +1,23 @@ +import { isVaultEnsoDisabled } from '@pages/vaults/constants/ensoDisabledVaults' import { useEnsoStatus } from '@pages/vaults/contexts/useEnsoStatus' +import type { Address } from 'viem' -export function useEnsoEnabled(): boolean { +interface UseEnsoEnabledOptions { + chainId?: number + vaultAddress?: Address +} + +export function useEnsoEnabled({ chainId, vaultAddress }: UseEnsoEnabledOptions = {}): boolean { const { isEnsoFailed } = useEnsoStatus() const envDisabled = import.meta.env.VITE_ENSO_DISABLED === 'true' - return !envDisabled && !isEnsoFailed + if (envDisabled || isEnsoFailed) { + return false + } + + if (isVaultEnsoDisabled(chainId, vaultAddress)) { + return false + } + + return true } 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..3ed992615 100644 --- a/src/components/pages/vaults/hooks/useSortVaults.ts +++ b/src/components/pages/vaults/hooks/useSortVaults.ts @@ -1,19 +1,25 @@ import { - getVaultAddress, getVaultAPR, getVaultChainID, getVaultFeaturingScore, getVaultInfo, getVaultName, - getVaultStaking, getVaultToken, getVaultTVL, type TKongVaultInput, type TKongVaultStrategy } from '@pages/vaults/domain/kongVaultSelectors' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' +import { + getYvUsdSharePrice, + isYvUsdVault, + YVUSD_CHAIN_ID, + YVUSD_LOCKED_ADDRESS, + YVUSD_UNLOCKED_ADDRESS +} from '@pages/vaults/utils/yvUsd' 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 +42,23 @@ export function useSortVaults { + const unlockedBalance = getBalance({ address: YVUSD_UNLOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }).normalized + const lockedBalance = getBalance({ address: YVUSD_LOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }).normalized + const unlockedSharePrice = getYvUsdSharePrice(yvUsdUnlockedVault) + const lockedSharePrice = getYvUsdSharePrice(yvUsdLockedVault) + return unlockedBalance * unlockedSharePrice + lockedBalance * lockedSharePrice + }, [getBalance, yvUsdLockedVault, yvUsdUnlockedVault]) + const yvUsdDisplayedApy = useMemo((): number => { + const lockedApy = yvUsdMetrics.locked.apy + if (lockedApy > 0 || yvUsdMetrics.unlocked.apy === 0) { + return lockedApy + } + return yvUsdMetrics.unlocked.apy + }, [yvUsdMetrics.locked.apy, yvUsdMetrics.unlocked.apy]) const isFeaturingScoreSortedDesc = useMemo((): boolean => { if (sortBy !== 'featuringScore' || sortDirection !== 'desc') { return false @@ -52,24 +73,54 @@ export function useSortVaults { + const sortedVaults = useMemo((): TVault[] => { if (sortDirection === '' || isFeaturingScoreSortedDesc) { return vaultList } const getDepositedValue = (vault: TKongVaultInput): number => { + if (isYvUsdVault(vault)) { + return yvUsdDepositedValue + } + return getVaultHoldingsUsd(vault) + } + + const getAvailableValue = (vault: TKongVaultInput): number => { + const token = getVaultToken(vault) const chainID = getVaultChainID(vault) - const address = getVaultAddress(vault) - const staking = getVaultStaking(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 vaultToken = getToken({ address, chainID }) - const vaultValue = vaultToken.value || 0 + const depositedValueByVault = new Map() + if (sortBy === 'deposited') { + vaultList.forEach((vault) => { + depositedValueByVault.set(vault, getDepositedValue(vault)) + }) + } - const stakingValue = !isZeroAddress(toAddress(staking?.address)) - ? getToken({ address: staking.address, chainID }).value || 0 - : 0 + const availableValueByVault = new Map() + if (sortBy === 'available') { + vaultList.forEach((vault) => { + availableValueByVault.set(vault, getAvailableValue(vault)) + }) + } + + const getApySortValue = (vault: TKongVaultInput): number => { + if (isYvUsdVault(vault)) { + return yvUsdDisplayedApy + } + return getVaultAPR(vault).netAPR || 0 + } - return vaultValue + stakingValue + const getEstimatedApySortValue = (vault: TKongVaultInput): number => { + if (isYvUsdVault(vault)) { + return yvUsdDisplayedApy + } + return calculateVaultEstimatedAPY(vault) } switch (sortBy) { @@ -84,17 +135,17 @@ export function useSortVaults sortWithFallback( - normalizeApyDisplayValue(calculateVaultEstimatedAPY(a)), - normalizeApyDisplayValue(calculateVaultEstimatedAPY(b)), - calculateVaultEstimatedAPY(a), - calculateVaultEstimatedAPY(b), + normalizeApyDisplayValue(getEstimatedApySortValue(a)), + normalizeApyDisplayValue(getEstimatedApySortValue(b)), + getEstimatedApySortValue(a), + getEstimatedApySortValue(b), sortDirection ) ) - case 'APY': { + case 'APY': return vaultList.toSorted((a, b): number => { - const aprA = getVaultAPR(a).netAPR || 0 - const aprB = getVaultAPR(b).netAPR || 0 + const aprA = getApySortValue(a) + const aprB = getApySortValue(b) return sortWithFallback( normalizeApyDisplayValue(aprA), normalizeApyDisplayValue(aprB), @@ -103,7 +154,6 @@ export function useSortVaults numberSort({ a: getVaultTVL(a).tvl, b: getVaultTVL(b).tvl, sortDirection }) @@ -125,31 +175,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 +202,16 @@ export function useSortVaults { @@ -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/useVaultsListModel.ts b/src/components/pages/vaults/hooks/useVaultsListModel.ts index 2195c91a3..2ae1885c6 100644 --- a/src/components/pages/vaults/hooks/useVaultsListModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsListModel.ts @@ -1,5 +1,12 @@ -import type { TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { + getVaultAddress, + getVaultChainID, + type TKongVault, + type TKongVaultInput, + type TKongVaultView +} from '@pages/vaults/domain/kongVaultSelectors' import { type TPossibleSortBy, useSortVaults } from '@pages/vaults/hooks/useSortVaults' +import { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' import { AGGRESSIVENESS_OPTIONS, AVAILABLE_TOGGLE_VALUE, @@ -9,6 +16,8 @@ import { } from '@pages/vaults/utils/constants' import type { TVaultAggressiveness } from '@pages/vaults/utils/vaultListFacets' import type { TVaultType } from '@pages/vaults/utils/vaultTypeCopy' +import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' +import { useWallet } from '@shared/contexts/useWallet' import { useV2VaultFilter } from '@shared/hooks/useV2VaultFilter' import { useV3VaultFilter } from '@shared/hooks/useV3VaultFilter' import { getVaultKey } from '@shared/hooks/useVaultFilterUtils' @@ -17,7 +26,7 @@ import { useMemo } from 'react' type TVaultsPinnedSection = { key: string - vaults: TKongVault[] + vaults: TKongVaultInput[] } type TVaultsListModelArgs = { @@ -41,19 +50,79 @@ type TVaultsListModelArgs = { type TVaultsListModel = { listCategoriesSanitized: string[] - holdingsVaults: TKongVault[] - availableVaults: TKongVault[] + holdingsVaults: TKongVaultInput[] + availableVaults: TKongVaultInput[] vaultFlags: Record underlyingAssetVaults: Record pinnedSections: TVaultsPinnedSection[] - pinnedVaults: TKongVault[] - mainVaults: TKongVault[] + pinnedVaults: TKongVaultInput[] + mainVaults: TKongVaultInput[] suggestedVaults: TKongVault[] totalMatchingVaults: number totalHoldingsMatching: number isLoadingVaultList: boolean } +function matchesYvUsdFilters({ + isV3View, + listChains, + listCategories, + listMinTvl, + searchValue, + yvUsdVault +}: { + isV3View: boolean + listChains: number[] | null + listCategories: string[] + listMinTvl: number + searchValue: string + yvUsdVault?: TKongVaultView +}): boolean { + if (!yvUsdVault || !isV3View) { + return false + } + + const matchesChain = !listChains?.length || listChains.includes(yvUsdVault.chainID) + const matchesCategory = listCategories.length === 0 || listCategories.includes(yvUsdVault.category) + const trimmedSearch = searchValue.trim().toLowerCase() + const matchesSearch = + trimmedSearch.length === 0 || + `${yvUsdVault.name} ${yvUsdVault.symbol} ${yvUsdVault.token.symbol} ${yvUsdVault.token.name} ${yvUsdVault.address}` + .toLowerCase() + .includes(trimmedSearch) + const minTvlValue = Number.isFinite(listMinTvl) ? Math.max(0, listMinTvl || 0) : 0 + const meetsMinTvl = (yvUsdVault.tvl?.tvl ?? 0) >= minTvlValue + + return matchesChain && matchesCategory && matchesSearch && meetsMinTvl +} + +function isYvUsdVariantVault(vault: TKongVaultInput): boolean { + const chainID = getVaultChainID(vault) + if (chainID !== YVUSD_CHAIN_ID) { + return false + } + + const address = getVaultAddress(vault) + return address === YVUSD_UNLOCKED_ADDRESS || address === YVUSD_LOCKED_ADDRESS +} + +function removeRawYvUsdVariants(vaults: TVault[]): TVault[] { + return vaults.filter((vault) => !isYvUsdVariantVault(vault)) +} + +function appendUniqueVault(vaults: TKongVaultInput[], vaultToAppend?: TKongVaultInput): TKongVaultInput[] { + if (!vaultToAppend) { + return vaults + } + + const nextKey = getVaultKey(vaultToAppend) + if (vaults.some((vault) => getVaultKey(vault) === nextKey)) { + return vaults + } + + return [...vaults, vaultToAppend] +} + export function useVaultsListModel({ enabled = true, listVaultType, @@ -73,8 +142,11 @@ export function useVaultsListModel({ isAvailablePinned }: TVaultsListModelArgs): TVaultsListModel { const isAllVaults = listVaultType === 'all' + const isV3View = enabled && (listVaultType === 'v3' || isAllVaults) const isV2View = enabled && (listVaultType === 'factory' || isAllVaults) + const { listVault: yvUsdVault } = useYvUsdVaults() + const { getBalance } = useWallet() const listV2Types = useMemo( () => (listShowLegacyVaults ? ['factory', 'legacy'] : ['factory']), @@ -93,6 +165,24 @@ export function useVaultsListModel({ ) }, [listAggressiveness]) + const shouldShowYvUsd = useMemo( + () => + matchesYvUsdFilters({ + isV3View, + listChains, + listCategories: listCategoriesSanitized, + listMinTvl, + searchValue, + yvUsdVault + }), + [isV3View, listChains, listCategoriesSanitized, listMinTvl, searchValue, yvUsdVault] + ) + + const yvUsdHasHoldings = useMemo(() => { + const unlockedBalance = getBalance({ address: YVUSD_UNLOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }).raw + const lockedBalance = getBalance({ address: YVUSD_LOCKED_ADDRESS, chainID: YVUSD_CHAIN_ID }).raw + return unlockedBalance > 0n || lockedBalance > 0n + }, [getBalance]) const v3FilterResult = useV3VaultFilter( isV3View ? listV3Types : null, listChains, @@ -129,26 +219,74 @@ export function useVaultsListModel({ isV2View ) - const filteredVaults = useMemo( - () => selectVaultsByType(listVaultType, v3FilterResult.filteredVaults, v2FilterResult.filteredVaults, true), - [listVaultType, v3FilterResult.filteredVaults, v2FilterResult.filteredVaults] + const sanitizedV3FilteredVaults = useMemo( + () => removeRawYvUsdVariants(v3FilterResult.filteredVaults), + [v3FilterResult.filteredVaults] + ) + + const sanitizedV3HoldingsVaults = useMemo( + () => removeRawYvUsdVariants(v3FilterResult.holdingsVaults), + [v3FilterResult.holdingsVaults] + ) + + const sanitizedV3AvailableVaults = useMemo( + () => removeRawYvUsdVariants(v3FilterResult.availableVaults), + [v3FilterResult.availableVaults] ) - const holdingsVaults = useMemo( - () => selectVaultsByType(listVaultType, v3FilterResult.holdingsVaults, v2FilterResult.holdingsVaults, true), - [listVaultType, v3FilterResult.holdingsVaults, v2FilterResult.holdingsVaults] + const filteredVaults = useMemo( + () => + selectVaultsByType( + listVaultType, + shouldShowYvUsd ? appendUniqueVault(sanitizedV3FilteredVaults, yvUsdVault) : sanitizedV3FilteredVaults, + v2FilterResult.filteredVaults, + true + ), + [listVaultType, sanitizedV3FilteredVaults, shouldShowYvUsd, v2FilterResult.filteredVaults, yvUsdVault] ) - const availableVaults = useMemo( - () => selectVaultsByType(listVaultType, v3FilterResult.availableVaults, v2FilterResult.availableVaults, true), - [listVaultType, v3FilterResult.availableVaults, v2FilterResult.availableVaults] + const holdingsVaults = useMemo( + () => + selectVaultsByType( + listVaultType, + shouldShowYvUsd && yvUsdHasHoldings + ? appendUniqueVault(sanitizedV3HoldingsVaults, yvUsdVault) + : sanitizedV3HoldingsVaults, + v2FilterResult.holdingsVaults, + true + ), + [ + listVaultType, + sanitizedV3HoldingsVaults, + shouldShowYvUsd, + yvUsdHasHoldings, + v2FilterResult.holdingsVaults, + yvUsdVault + ] ) - const vaultFlags = useMemo( - () => selectVaultsByType(listVaultType, v3FilterResult.vaultFlags, v2FilterResult.vaultFlags), - [listVaultType, v3FilterResult.vaultFlags, v2FilterResult.vaultFlags] + const availableVaults = useMemo( + () => selectVaultsByType(listVaultType, sanitizedV3AvailableVaults, v2FilterResult.availableVaults, true), + [listVaultType, sanitizedV3AvailableVaults, v2FilterResult.availableVaults] ) + const vaultFlags = useMemo(() => { + const baseFlags = selectVaultsByType(listVaultType, v3FilterResult.vaultFlags, v2FilterResult.vaultFlags) + if (!yvUsdVault) { + return baseFlags + } + const yvUsdKey = getVaultKey(yvUsdVault) + return { + ...baseFlags, + [yvUsdKey]: { + hasHoldings: yvUsdHasHoldings, + isMigratable: false, + isRetired: false, + isHidden: false + } + } + }, [listVaultType, v3FilterResult.vaultFlags, v2FilterResult.vaultFlags, yvUsdHasHoldings, yvUsdVault]) + const isLoadingVaultList = listVaultType === 'all' ? v3FilterResult.isLoading || v2FilterResult.isLoading @@ -157,28 +295,12 @@ export function useVaultsListModel({ : v2FilterResult.isLoading const totalMatchingVaults = useMemo(() => { - const v3Total = v3FilterResult.totalMatchingVaults ?? 0 - const v2Total = v2FilterResult.filteredVaults.length - if (listVaultType === 'v3') { - return v3Total - } - if (listVaultType === 'factory') { - return v2Total - } - return v3Total + v2Total - }, [listVaultType, v3FilterResult.totalMatchingVaults, v2FilterResult.filteredVaults.length]) + return filteredVaults.length + }, [filteredVaults.length]) const totalHoldingsMatching = useMemo(() => { - const v3Total = v3FilterResult.totalHoldingsMatching ?? 0 - const v2Total = v2FilterResult.holdingsVaults.length - if (listVaultType === 'v3') { - return v3Total - } - if (listVaultType === 'factory') { - return v2Total - } - return v3Total + v2Total - }, [listVaultType, v3FilterResult.totalHoldingsMatching, v2FilterResult.holdingsVaults.length]) + return holdingsVaults.length + }, [holdingsVaults.length]) const allocatorTypesForTrending = useMemo(() => (isV3View ? ['multi'] : null), [isV3View]) @@ -216,15 +338,18 @@ export function useVaultsListModel({ [sortedVaults, availableKeySet] ) - const sortedSuggestedV3Candidates = useSortVaults(filteredVaultsAllChains, 'featuringScore', 'desc') + const sortedSuggestedV3Candidates = useSortVaults( + removeRawYvUsdVariants(filteredVaultsAllChains), + 'featuringScore', + 'desc' + ) const sortedSuggestedV2Candidates = useSortVaults(filteredV2VaultsAllChains, 'featuringScore', 'desc') const pinnedSections = useMemo(() => { const sections: TVaultsPinnedSection[] = [] const seen = new Set() - - if (isAvailablePinned) { - const availableSectionVaults = sortedAvailableVaults.filter((vault) => { + const takeUnseenVaults = (vaults: TKongVaultInput[]): TKongVaultInput[] => + vaults.filter((vault) => { const key = getVaultKey(vault) if (seen.has(key)) { return false @@ -233,6 +358,18 @@ export function useVaultsListModel({ return true }) + if (shouldShowYvUsd && yvUsdVault) { + const yvUsdSectionVaults = takeUnseenVaults([yvUsdVault]) + if (yvUsdSectionVaults.length > 0) { + sections.push({ + key: 'yvUSD', + vaults: yvUsdSectionVaults + }) + } + } + + if (isAvailablePinned) { + const availableSectionVaults = takeUnseenVaults(sortedAvailableVaults) if (availableSectionVaults.length > 0) { sections.push({ key: AVAILABLE_TOGGLE_VALUE, @@ -244,15 +381,7 @@ export function useVaultsListModel({ if (isHoldingsPinned) { const holdingsSourceVaults = holdingsPinnedSortDirection === '' ? sortedHoldingsVaults : sortedHoldingsVaultsByDeposited - const holdingsSectionVaults = holdingsSourceVaults.filter((vault) => { - const key = getVaultKey(vault) - if (seen.has(key)) { - return false - } - seen.add(key) - return true - }) - + const holdingsSectionVaults = takeUnseenVaults(holdingsSourceVaults) if (holdingsSectionVaults.length > 0) { sections.push({ key: HOLDINGS_TOGGLE_VALUE, @@ -264,11 +393,13 @@ export function useVaultsListModel({ return sections }, [ isAvailablePinned, - sortedAvailableVaults, - isHoldingsPinned, holdingsPinnedSortDirection, + isHoldingsPinned, sortedHoldingsVaults, - sortedHoldingsVaultsByDeposited + sortedHoldingsVaultsByDeposited, + sortedAvailableVaults, + shouldShowYvUsd, + yvUsdVault ]) const pinnedVaults = useMemo(() => pinnedSections.flatMap((section) => section.vaults), [pinnedSections]) diff --git a/src/components/pages/vaults/hooks/useVaultsPageModel.ts b/src/components/pages/vaults/hooks/useVaultsPageModel.ts index f0a9ab112..3723dd1fd 100644 --- a/src/components/pages/vaults/hooks/useVaultsPageModel.ts +++ b/src/components/pages/vaults/hooks/useVaultsPageModel.ts @@ -63,14 +63,14 @@ 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 - vaults: TKongVault[] + vaults: TKongVaultInput[] } type TVaultsBlockingFilterActionKey = @@ -162,8 +162,8 @@ type TVaultsFiltersBarModel = { type TVaultsListData = { isLoading: boolean pinnedSections: TVaultsPinnedSection[] - pinnedVaults: TKongVault[] - mainVaults: TKongVault[] + pinnedVaults: TKongVaultInput[] + mainVaults: TKongVaultInput[] vaultFlags: Record listChains: number[] | null totalMatchingVaults: number diff --git a/src/components/pages/vaults/hooks/useYvUsdAprService.ts b/src/components/pages/vaults/hooks/useYvUsdAprService.ts new file mode 100644 index 000000000..eb0248b6d --- /dev/null +++ b/src/components/pages/vaults/hooks/useYvUsdAprService.ts @@ -0,0 +1,44 @@ +import { useFetch } from '@shared/hooks/useFetch' +import { toAddress } from '@shared/utils' +import { + type TYvUsdAprServiceResponse, + type TYvUsdAprServiceVault, + yvUsdAprServiceSchema +} from '@shared/utils/schemas/yvUsdAprServiceSchema' +import { YVUSD_APR_SERVICE_ENDPOINT, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '../utils/yvUsd' + +type TYvUsdAprServiceData = { + unlocked?: TYvUsdAprServiceVault + locked?: TYvUsdAprServiceVault + isLoading: boolean + error?: Error +} + +function getAprServiceVault( + data: TYvUsdAprServiceResponse | undefined, + address: string +): TYvUsdAprServiceVault | undefined { + return Object.values(data ?? {}).find((vault) => toAddress(vault.address) === address) +} + +export function useYvUsdAprService(): TYvUsdAprServiceData { + const { data, isLoading, error } = useFetch({ + endpoint: YVUSD_APR_SERVICE_ENDPOINT, + schema: yvUsdAprServiceSchema, + config: { + cacheDuration: 30 * 1000, + shouldEnableRefreshInterval: true, + refreshInterval: 30 * 1000 + } + }) + + const unlocked = getAprServiceVault(data, YVUSD_UNLOCKED_ADDRESS) + const locked = getAprServiceVault(data, YVUSD_LOCKED_ADDRESS) + + return { + unlocked, + locked, + isLoading, + error: error ?? undefined + } +} diff --git a/src/components/pages/vaults/hooks/useYvUsdCharts.ts b/src/components/pages/vaults/hooks/useYvUsdCharts.ts new file mode 100644 index 000000000..6503864bd --- /dev/null +++ b/src/components/pages/vaults/hooks/useYvUsdCharts.ts @@ -0,0 +1,125 @@ +import { transformVaultChartData } from '@pages/vaults/utils/charts' +import { useMemo } from 'react' +import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '../utils/yvUsd' +import { useVaultChartTimeseries } from './useVaultChartTimeseries' + +export type TYvUsdSeriesPoint = { + date: string + unlocked: number | null + locked: number | null +} + +type TYvUsdCharts = { + apyData?: TYvUsdSeriesPoint[] + performanceData?: TYvUsdSeriesPoint[] + tvlData?: TYvUsdSeriesPoint[] + isLoading: boolean + error?: Error +} + +type TSeriesPointWithDate = { + date: string +} + +type TApyPointValue = { + thirtyDayApy?: number | null + sevenDayApy?: number | null + derivedApy?: number | null + derivedApr?: number | null +} + +function getApyPointValue(point: TApyPointValue | undefined): number | null { + return point?.thirtyDayApy ?? point?.sevenDayApy ?? point?.derivedApy ?? point?.derivedApr ?? null +} + +function getNullableSeriesValue(value: number | null | undefined): number | null { + return value ?? null +} + +function mergeByDate({ + unlockedSeries, + lockedSeries, + getUnlockedValue, + getLockedValue +}: { + unlockedSeries: TUnlocked[] | null + lockedSeries: TLocked[] | null + getUnlockedValue: (point: TUnlocked | undefined) => number | null + getLockedValue: (point: TLocked | undefined) => number | null +}): TYvUsdSeriesPoint[] | undefined { + const unlockedList = unlockedSeries ?? [] + const lockedList = lockedSeries ?? [] + if (unlockedList.length === 0 && lockedList.length === 0) { + return undefined + } + + const unlockedByDate = new Map(unlockedList.map((point) => [point.date, point])) + const lockedByDate = new Map(lockedList.map((point) => [point.date, point])) + const orderedDates = [ + ...new Set([...unlockedList.map((point) => point.date), ...lockedList.map((point) => point.date)]) + ] + + return orderedDates.map((date) => ({ + date, + unlocked: getUnlockedValue(unlockedByDate.get(date)), + locked: getLockedValue(lockedByDate.get(date)) + })) +} + +export function useYvUsdCharts(): TYvUsdCharts { + const { + data: unlockedData, + isLoading: isLoadingUnlocked, + error: unlockedError + } = useVaultChartTimeseries({ + chainId: YVUSD_CHAIN_ID, + address: YVUSD_UNLOCKED_ADDRESS + }) + + const { + data: lockedData, + isLoading: isLoadingLocked, + error: lockedError + } = useVaultChartTimeseries({ + chainId: YVUSD_CHAIN_ID, + address: YVUSD_LOCKED_ADDRESS + }) + + const unlockedTransformed = useMemo(() => transformVaultChartData(unlockedData), [unlockedData]) + const lockedTransformed = useMemo(() => transformVaultChartData(lockedData), [lockedData]) + + const apyData = useMemo(() => { + return mergeByDate({ + unlockedSeries: unlockedTransformed.aprApyData, + lockedSeries: lockedTransformed.aprApyData, + getUnlockedValue: getApyPointValue, + getLockedValue: getApyPointValue + }) + }, [lockedTransformed.aprApyData, unlockedTransformed.aprApyData]) + + const performanceData = useMemo(() => { + return mergeByDate({ + unlockedSeries: unlockedTransformed.ppsData, + lockedSeries: lockedTransformed.ppsData, + getUnlockedValue: (point) => getNullableSeriesValue(point?.PPS), + getLockedValue: (point) => getNullableSeriesValue(point?.PPS) + }) + }, [lockedTransformed.ppsData, unlockedTransformed.ppsData]) + + const tvlData = useMemo(() => { + return mergeByDate({ + unlockedSeries: unlockedTransformed.tvlData, + lockedSeries: lockedTransformed.tvlData, + getUnlockedValue: (point) => getNullableSeriesValue(point?.TVL), + getLockedValue: (point) => getNullableSeriesValue(point?.TVL) + }) + }, [lockedTransformed.tvlData, unlockedTransformed.tvlData]) + + return { + apyData, + performanceData, + tvlData, + isLoading: isLoadingUnlocked || isLoadingLocked || !apyData || !performanceData || !tvlData, + error: (unlockedError ?? lockedError) as Error | undefined + } +} diff --git a/src/components/pages/vaults/hooks/useYvUsdVaults.ts b/src/components/pages/vaults/hooks/useYvUsdVaults.ts new file mode 100644 index 000000000..2c49b625e --- /dev/null +++ b/src/components/pages/vaults/hooks/useYvUsdVaults.ts @@ -0,0 +1,528 @@ +import { + getVaultView, + type TKongVaultInput, + type TKongVaultStrategy, + type TKongVaultView +} from '@pages/vaults/domain/kongVaultSelectors' +import { useYearn } from '@shared/contexts/useYearn' +import { toAddress, toBigInt, toNormalizedBN } from '@shared/utils' +import type { TKongVaultListItem } from '@shared/utils/schemas/kongVaultListSchema' +import type { TKongVaultSnapshot } from '@shared/utils/schemas/kongVaultSnapshotSchema' +import type { TYvUsdAprServiceStrategy, TYvUsdAprServiceVault } from '@shared/utils/schemas/yvUsdAprServiceSchema' +import { useMemo } from 'react' +import { YVUSD_CHAIN_ID, YVUSD_DESCRIPTION, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '../utils/yvUsd' +import { useVaultSnapshot } from './useVaultSnapshot' +import { useYvUsdAprService } from './useYvUsdAprService' + +type TYvUsdMetrics = { + apy: number + tvl: number + hasInfinifiPoints: boolean +} + +type TYvUsdVaults = { + assetAddress: `0x${string}` + baseVault: TKongVaultView + listVault: TKongVaultView + unlockedVault: TKongVaultView + lockedVault: TKongVaultView + metrics: { + unlocked: TYvUsdMetrics + locked: TYvUsdMetrics + } + isLoading: boolean +} + +const MAX_REASONABLE_FORWARD_APY = 1 +const APR_RAW_DECIMALS = 18 + +function toFiniteNumber(value: number | null | undefined): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return value +} + +function getVaultApy(vault: TKongVaultView): number { + return toFiniteNumber(vault.apr.forwardAPR.netAPR) ?? toFiniteNumber(vault.apr.netAPR) ?? 0 +} + +function getVaultTvl(vault: TKongVaultView): number { + return vault.tvl.tvl ?? 0 +} + +type TSnapshotApyMetric = 'net' | 'weeklyNet' | 'monthlyNet' +type TYvUsdVariantDefaults = { + forwardApy: number + netApy: number + weeklyApy: number + monthlyApy: number + tvl: number + totalAssets: bigint + price: number +} + +function resolveSnapshotHistoricalApy( + snapshot: TKongVaultSnapshot | undefined, + metric: TSnapshotApyMetric +): number | undefined { + return toFiniteNumber(snapshot?.apy?.[metric]) ?? toFiniteNumber(snapshot?.performance?.historical?.[metric]) +} + +function resolveSnapshotNetApy(snapshot?: TKongVaultSnapshot): number | undefined { + return resolveSnapshotHistoricalApy(snapshot, 'net') +} + +function resolveSnapshotWeeklyApy(snapshot?: TKongVaultSnapshot): number | undefined { + return resolveSnapshotHistoricalApy(snapshot, 'weeklyNet') +} + +function resolveSnapshotMonthlyApy(snapshot?: TKongVaultSnapshot): number | undefined { + return resolveSnapshotHistoricalApy(snapshot, 'monthlyNet') +} + +function resolveSnapshotForwardApy(snapshot?: TKongVaultSnapshot): number | undefined { + const estimatedApy = toFiniteNumber(snapshot?.performance?.estimated?.apy) + if (estimatedApy !== undefined) { + return estimatedApy + } + + const estimatedApr = toFiniteNumber(snapshot?.performance?.estimated?.apr) + if (estimatedApr !== undefined) { + return estimatedApr + } + + for (const oracleValue of [ + toFiniteNumber(snapshot?.performance?.oracle?.apy), + toFiniteNumber(snapshot?.performance?.oracle?.apr) + ]) { + if (oracleValue !== undefined && Math.abs(oracleValue) <= MAX_REASONABLE_FORWARD_APY) { + return oracleValue + } + } + + return resolveSnapshotNetApy(snapshot) +} + +function resolveSnapshotTvl(snapshot?: TKongVaultSnapshot): number | undefined { + return toFiniteNumber(snapshot?.tvl?.close) +} + +function normalizedToRawAmount(value: number, decimals: number): bigint { + if (!Number.isFinite(value) || value <= 0) { + return 0n + } + + const precision = Math.min(decimals, 12) + const fixed = value.toFixed(precision) + const [wholePart, fractionalPart = ''] = fixed.split('.') + const rawString = `${wholePart}${fractionalPart.padEnd(precision, '0')}` + const scaled = BigInt(rawString) + + if (precision === decimals) { + return scaled + } + + return scaled * 10n ** BigInt(decimals - precision) +} + +type TYvUsdAprOverlay = { + apr?: number + apy?: number + strategies?: TKongVaultStrategy[] +} + +function normalizeAprRaw(value: string | null | undefined): number | null { + if (!value) return null + try { + const normalized = toNormalizedBN(value, APR_RAW_DECIMALS).normalized + if (!Number.isFinite(normalized)) { + return null + } + return normalized + } catch { + return null + } +} + +function normalizeWeightToDebtRatio(weight: number | undefined): number { + if (weight === undefined || !Number.isFinite(weight)) { + return 0 + } + return Math.max(0, Math.min(10_000, Math.round(weight * 10_000))) +} + +function hasDebt(debt: string): boolean { + try { + return toBigInt(debt) > 0n + } catch { + return false + } +} + +function mapApiStrategy(strategy: TYvUsdAprServiceStrategy, index: number): TKongVaultStrategy { + const netApr = normalizeAprRaw(strategy.net_apr_raw || strategy.apr_raw) + const debt = strategy.debt || '0' + const debtRatio = normalizeWeightToDebtRatio(strategy.weight) + const strategyName = strategy.meta?.name?.trim() || `Strategy ${index + 1}` + const isAllocated = hasDebt(debt) && debtRatio > 0 + + return { + address: toAddress(strategy.address), + name: strategyName, + description: '', + netAPR: netApr, + estimatedAPY: netApr ?? undefined, + status: isAllocated ? 'active' : 'unallocated', + details: { + totalDebt: debt, + totalLoss: '0', + totalGain: '0', + performanceFee: 0, + lastReport: 0, + debtRatio + } + } +} + +function buildAprOverlay(vault?: TYvUsdAprServiceVault): TYvUsdAprOverlay | undefined { + if (!vault) { + return undefined + } + + const strategies = (vault.meta?.strategies || []).map(mapApiStrategy) + const apr = toFiniteNumber(vault.apr ?? undefined) + const apy = toFiniteNumber(vault.apy ?? undefined) + const resolvedStrategies = strategies.length > 0 ? strategies : undefined + + if (apr === undefined && apy === undefined && !resolvedStrategies) { + return undefined + } + + return { + apr, + apy, + strategies: resolvedStrategies + } +} + +function hasInfinifiPoints(vault?: TYvUsdAprServiceVault): boolean { + return (vault?.meta?.strategies || []).some((strategy) => strategy.points === true) +} + +function resolveAddress(value?: string): `0x${string}` | undefined { + if (!value) return undefined + try { + return toAddress(value) + } catch { + return undefined + } +} + +const FALLBACK_ASSET = { + address: toAddress('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'), + name: 'USD Coin', + symbol: 'USDC', + decimals: 6 +} as const + +function buildSyntheticBaseVault(snapshot?: TKongVaultSnapshot): TKongVaultListItem { + const token = snapshot?.meta?.token + const asset = snapshot?.asset + const resolvedAsset = { + address: toAddress(token?.address ?? asset?.address ?? FALLBACK_ASSET.address), + name: token?.name || asset?.name || FALLBACK_ASSET.name, + symbol: token?.symbol || asset?.symbol || FALLBACK_ASSET.symbol, + decimals: token?.decimals ?? asset?.decimals ?? FALLBACK_ASSET.decimals + } + + return { + chainId: YVUSD_CHAIN_ID, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + symbol: 'yvUSD', + apiVersion: snapshot?.apiVersion ?? '3.0.0', + decimals: snapshot?.decimals ?? 18, + asset: resolvedAsset, + tvl: toFiniteNumber(snapshot?.tvl?.close) ?? null, + performance: null, + fees: { + managementFee: toFiniteNumber(snapshot?.fees?.managementFee) ?? 0, + performanceFee: toFiniteNumber(snapshot?.fees?.performanceFee) ?? 0 + }, + category: 'Stablecoin', + type: snapshot?.meta?.type || 'Automated Yearn Vault', + kind: snapshot?.meta?.kind || 'Multi Strategy', + v3: true, + yearn: true, + isRetired: Boolean(snapshot?.meta?.isRetired), + isHidden: Boolean(snapshot?.meta?.isHidden), + isBoosted: Boolean(snapshot?.meta?.isBoosted), + isHighlighted: true, + inclusion: { isYearn: true }, + migration: false, + origin: 'synthetic-yvusd', + strategiesCount: snapshot?.composition?.length ?? snapshot?.debts?.length ?? 0, + riskLevel: toFiniteNumber(snapshot?.risk?.riskLevel) ?? null, + staking: null + } +} + +function getVariantDefaults(baseVariant: TKongVaultView, fallbackToBase: boolean): TYvUsdVariantDefaults { + if (!fallbackToBase) { + return { + forwardApy: 0, + netApy: 0, + weeklyApy: 0, + monthlyApy: 0, + tvl: 0, + totalAssets: 0n, + price: 0 + } + } + + return { + forwardApy: baseVariant.apr.forwardAPR.netAPR, + netApy: baseVariant.apr.netAPR, + weeklyApy: baseVariant.apr.points.weekAgo, + monthlyApy: baseVariant.apr.points.monthAgo, + tvl: baseVariant.tvl.tvl, + totalAssets: baseVariant.tvl.totalAssets, + price: baseVariant.tvl.price + } +} + +function getVariantPrice(resolvedTvl: number, normalizedAssets: number, fallbackPrice: number): number { + if (resolvedTvl > 0 && normalizedAssets > 0) { + return resolvedTvl / normalizedAssets + } + + return fallbackPrice +} + +function buildVariantVault({ + baseVault, + snapshot, + address, + name, + fallbackToBase, + aprOverlay +}: { + baseVault: TKongVaultInput + snapshot?: TKongVaultSnapshot + address: string + name: string + fallbackToBase: boolean + aprOverlay?: TYvUsdAprOverlay +}): TKongVaultView { + const normalizedAddress = toAddress(address) + const baseVariant = getVaultView(baseVault, snapshot) + const defaults = getVariantDefaults(baseVariant, fallbackToBase) + + const resolvedNetApy = resolveSnapshotNetApy(snapshot) ?? aprOverlay?.apr ?? defaults.netApy + const resolvedWeeklyApy = resolveSnapshotWeeklyApy(snapshot) ?? defaults.weeklyApy + const resolvedMonthlyApy = resolveSnapshotMonthlyApy(snapshot) ?? defaults.monthlyApy + const resolvedForwardApy = aprOverlay?.apy ?? resolveSnapshotForwardApy(snapshot) ?? defaults.forwardApy + const resolvedTvl = resolveSnapshotTvl(snapshot) ?? defaults.tvl + const resolvedTotalAssets = snapshot?.totalAssets ? toBigInt(snapshot.totalAssets) : defaults.totalAssets + const normalizedAssets = toNormalizedBN(resolvedTotalAssets, baseVariant.token.decimals).normalized + const resolvedPrice = getVariantPrice(resolvedTvl, normalizedAssets, defaults.price) + const resolvedStrategies = aprOverlay?.strategies ?? baseVariant.strategies + const forwardAprType = aprOverlay?.apy !== undefined ? 'yvusd-apr-service' : baseVariant.apr.forwardAPR.type + + return { + ...baseVariant, + address: normalizedAddress, + chainID: YVUSD_CHAIN_ID, + name, + symbol: 'yvUSD', + description: YVUSD_DESCRIPTION, + category: 'Stablecoin', + apr: { + ...baseVariant.apr, + netAPR: resolvedNetApy, + points: { + ...baseVariant.apr.points, + weekAgo: resolvedWeeklyApy, + monthAgo: resolvedMonthlyApy + }, + forwardAPR: { + ...baseVariant.apr.forwardAPR, + type: forwardAprType, + netAPR: resolvedForwardApy + } + }, + tvl: { + ...baseVariant.tvl, + totalAssets: resolvedTotalAssets, + tvl: resolvedTvl, + price: resolvedPrice + }, + strategies: resolvedStrategies + } +} + +function getCombinedListPrice( + baseAssetPrice: number, + combinedTvl: number, + combinedAssetsNormalized: number, + fallbackPrice: number +): number { + if (baseAssetPrice > 0) { + return baseAssetPrice + } + + if (combinedTvl > 0 && combinedAssetsNormalized > 0) { + return combinedTvl / combinedAssetsNormalized + } + + return fallbackPrice +} + +function buildListVault({ + baseVault, + unlocked, + locked +}: { + baseVault: TKongVaultInput + unlocked: TKongVaultView + locked: TKongVaultView +}): TKongVaultView { + const baseView = getVaultView(baseVault) + const combinedTvl = (unlocked.tvl.tvl ?? 0) + (locked.tvl.tvl ?? 0) + const baseAssetPrice = unlocked.tvl.price || baseView.tvl.price + const unlockedAssetsNormalized = toNormalizedBN(unlocked.tvl.totalAssets, baseView.token.decimals).normalized + const lockedAssetsNormalized = baseAssetPrice > 0 ? locked.tvl.tvl / baseAssetPrice : 0 + const combinedAssetsNormalized = unlockedAssetsNormalized + lockedAssetsNormalized + const combinedTotalAssets = normalizedToRawAmount(combinedAssetsNormalized, baseView.token.decimals) + const combinedPrice = getCombinedListPrice(baseAssetPrice, combinedTvl, combinedAssetsNormalized, baseView.tvl.price) + + return { + ...baseView, + address: YVUSD_UNLOCKED_ADDRESS, + chainID: YVUSD_CHAIN_ID, + name: 'yvUSD', + symbol: 'yvUSD', + description: YVUSD_DESCRIPTION, + category: 'Stablecoin', + tvl: { + ...baseView.tvl, + totalAssets: combinedTotalAssets, + tvl: combinedTvl, + price: combinedPrice + }, + apr: { + ...baseView.apr, + netAPR: unlocked.apr.netAPR, + forwardAPR: { + ...baseView.apr.forwardAPR, + netAPR: unlocked.apr.forwardAPR.netAPR + } + }, + info: { + ...baseView.info, + isHighlighted: true, + uiNotice: YVUSD_DESCRIPTION + }, + strategies: unlocked.strategies ?? baseView.strategies, + featuringScore: Math.max(baseView.featuringScore ?? 0, 9_999) + } +} + +export function useYvUsdVaults(): TYvUsdVaults { + const { vaults, isLoadingVaultList } = useYearn() + const { unlocked: unlockedAprServiceVault, locked: lockedAprServiceVault } = useYvUsdAprService() + + const { data: unlockedSnapshot, isLoading: isLoadingUnlocked } = useVaultSnapshot({ + chainId: YVUSD_CHAIN_ID, + address: YVUSD_UNLOCKED_ADDRESS + }) + + const { data: lockedSnapshot, isLoading: isLoadingLocked } = useVaultSnapshot({ + chainId: YVUSD_CHAIN_ID, + address: YVUSD_LOCKED_ADDRESS + }) + + const baseVault = useMemo(() => { + const baseCandidates = [YVUSD_UNLOCKED_ADDRESS, YVUSD_LOCKED_ADDRESS] + const listVaultCandidate = baseCandidates.map((address) => vaults[toAddress(address)]).find(Boolean) + if (listVaultCandidate) { + return listVaultCandidate + } + return buildSyntheticBaseVault(unlockedSnapshot ?? lockedSnapshot) + }, [lockedSnapshot, unlockedSnapshot, vaults]) + const baseVaultView = useMemo((): TKongVaultView => getVaultView(baseVault), [baseVault]) + + const unlockedAprOverlay = useMemo( + (): TYvUsdAprOverlay | undefined => buildAprOverlay(unlockedAprServiceVault), + [unlockedAprServiceVault] + ) + const lockedAprOverlay = useMemo( + (): TYvUsdAprOverlay | undefined => buildAprOverlay(lockedAprServiceVault), + [lockedAprServiceVault] + ) + const assetAddress = useMemo((): `0x${string}` => { + const baseAssetAddress = 'asset' in baseVault ? baseVault.asset?.address : undefined + return ( + resolveAddress(unlockedAprServiceVault?.meta?.asset) || + resolveAddress(lockedAprServiceVault?.meta?.asset) || + resolveAddress(baseAssetAddress) || + FALLBACK_ASSET.address + ) + }, [baseVault, lockedAprServiceVault, unlockedAprServiceVault]) + + const unlockedVault = useMemo((): TKongVaultView => { + return buildVariantVault({ + baseVault, + snapshot: unlockedSnapshot, + address: YVUSD_UNLOCKED_ADDRESS, + name: 'yvUSD', + fallbackToBase: true, + aprOverlay: unlockedAprOverlay + }) + }, [baseVault, unlockedAprOverlay, unlockedSnapshot]) + + const lockedVault = useMemo((): TKongVaultView => { + return buildVariantVault({ + baseVault, + snapshot: lockedSnapshot, + address: YVUSD_LOCKED_ADDRESS, + name: 'yvUSD (Locked)', + fallbackToBase: false, + aprOverlay: lockedAprOverlay + }) + }, [baseVault, lockedAprOverlay, lockedSnapshot]) + + const listVault = useMemo((): TKongVaultView => { + return buildListVault({ + baseVault, + unlocked: unlockedVault, + locked: lockedVault + }) + }, [baseVault, unlockedVault, lockedVault]) + + const metrics = useMemo(() => { + return { + unlocked: { + apy: getVaultApy(unlockedVault), + tvl: getVaultTvl(unlockedVault), + hasInfinifiPoints: hasInfinifiPoints(unlockedAprServiceVault) + }, + locked: { + apy: getVaultApy(lockedVault), + tvl: getVaultTvl(lockedVault), + hasInfinifiPoints: hasInfinifiPoints(lockedAprServiceVault) + } + } + }, [unlockedVault, lockedVault, unlockedAprServiceVault, lockedAprServiceVault]) + + return { + assetAddress, + baseVault: baseVaultView, + listVault, + unlockedVault, + lockedVault, + metrics, + isLoading: isLoadingVaultList || isLoadingUnlocked || isLoadingLocked + } +} 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/vaultLogo.test.ts b/src/components/pages/vaults/utils/vaultLogo.test.ts new file mode 100644 index 000000000..685695f12 --- /dev/null +++ b/src/components/pages/vaults/utils/vaultLogo.test.ts @@ -0,0 +1,39 @@ +import type { TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { describe, expect, it } from 'vitest' +import { getVaultPrimaryLogoSrc } from './vaultLogo' +import { YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from './yvUsd' + +const STANDARD_VAULT = { + version: '3.0.0', + chainID: 1, + address: '0x0000000000000000000000000000000000000001', + token: { + address: '0x0000000000000000000000000000000000000002', + symbol: 'TKN', + decimals: 18 + } +} + +describe('getVaultPrimaryLogoSrc', () => { + it('returns the yvUSD seal logo for the unlocked vault', () => { + expect( + getVaultPrimaryLogoSrc({ + ...STANDARD_VAULT, + address: YVUSD_UNLOCKED_ADDRESS + } as unknown as TKongVaultInput) + ).toMatch(/yvUSD-seal\.png$/) + }) + + it('returns the yvUSD seal logo for the locked vault', () => { + expect( + getVaultPrimaryLogoSrc({ + ...STANDARD_VAULT, + address: YVUSD_LOCKED_ADDRESS + } as unknown as TKongVaultInput) + ).toMatch(/yvUSD-seal\.png$/) + }) + + it('returns the standard token asset logo for non-yvUSD vaults', () => { + expect(getVaultPrimaryLogoSrc(STANDARD_VAULT as unknown as TKongVaultInput)).toContain('logo-128.png') + }) +}) diff --git a/src/components/pages/vaults/utils/vaultLogo.ts b/src/components/pages/vaults/utils/vaultLogo.ts new file mode 100644 index 000000000..72501a535 --- /dev/null +++ b/src/components/pages/vaults/utils/vaultLogo.ts @@ -0,0 +1,21 @@ +import { getVaultChainID, getVaultToken, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { toAddress } from '@shared/utils' +import { isYvUsdVault } from './yvUsd' + +function getBaseUrl(): string { + return import.meta.env.BASE_URL || '/' +} + +function getAssetsBaseUrl(): string { + return import.meta.env.VITE_BASE_YEARN_ASSETS_URI || '' +} + +export function getVaultPrimaryLogoSrc(vault: TKongVaultInput): string { + if (isYvUsdVault(vault)) { + return `${getBaseUrl()}yvUSD-seal.png` + } + + const chainID = getVaultChainID(vault) + const token = getVaultToken(vault) + return `${getAssetsBaseUrl()}/tokens/${chainID}/${toAddress(token.address).toLowerCase()}/logo-128.png` +} 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/pages/vaults/utils/yvUsd.test.ts b/src/components/pages/vaults/utils/yvUsd.test.ts new file mode 100644 index 000000000..4d011cee3 --- /dev/null +++ b/src/components/pages/vaults/utils/yvUsd.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { + convertYvUsdLockedAssetRawAmountToUnderlying, + convertYvUsdLockedPricePerShareToUnderlying, + convertYvUsdUnderlyingRawAmountToLockedAsset, + convertYvUsdVariantAmountString, + convertYvUsdVariantRawAmount, + getWeightedYvUsdApy, + getYvUsdLockedWithdrawDisplayMode, + YVUSD_CUSTOM_RISK_SCORE, + YVUSD_RISK_SCORE_ITEMS +} from './yvUsd' + +describe('getWeightedYvUsdApy', () => { + it('returns the unlocked APY when only unlocked value is present', () => { + expect( + getWeightedYvUsdApy({ + unlockedValue: 100, + lockedValue: 0, + unlockedApy: 0.05, + lockedApy: 0.09 + }) + ).toBeCloseTo(0.05, 6) + }) + + it('returns the locked APY when only locked value is present', () => { + expect( + getWeightedYvUsdApy({ + unlockedValue: 0, + lockedValue: 200, + unlockedApy: 0.05, + lockedApy: 0.09 + }) + ).toBeCloseTo(0.09, 6) + }) + + it('weights unlocked and locked APYs by position value', () => { + expect( + getWeightedYvUsdApy({ + unlockedValue: 100, + lockedValue: 100, + unlockedApy: 0.05, + lockedApy: 0.09 + }) + ).toBeCloseTo(0.07, 6) + }) + + it('keeps missing-APY value in the denominator for conservative weighting', () => { + expect( + getWeightedYvUsdApy({ + unlockedValue: 100, + lockedValue: 100, + unlockedApy: null, + lockedApy: 0.09 + }) + ).toBeCloseTo(0.045, 6) + }) + + it('returns null when there is no value to weight', () => { + expect( + getWeightedYvUsdApy({ + unlockedValue: 0, + lockedValue: 0, + unlockedApy: 0.05, + lockedApy: 0.09 + }) + ).toBeNull() + }) +}) + +describe('yvUSD risk override', () => { + it('uses the provisional custom score for the detail risk section', () => { + expect(YVUSD_CUSTOM_RISK_SCORE).toBe('3/5') + expect(YVUSD_RISK_SCORE_ITEMS[0]?.score).toBe('3/5') + }) + + it('keeps the current published risk sections intact', () => { + expect(YVUSD_RISK_SCORE_ITEMS.map((item) => item.label)).toEqual([ + 'Overall Risk Score', + 'Leverage Looping', + 'Duration and PT Strategies', + 'Cross-Chain Routing' + ]) + }) +}) + +describe('yvUSD variant amount conversion', () => { + const unlockedPricePerShare = 1_050_000n + const unlockedVaultDecimals = 18 + + it('converts unlocked underlying assets into locked yvUSD shares', () => { + expect( + convertYvUsdVariantRawAmount({ + amount: 100_000_000n, + fromVariant: 'unlocked', + toVariant: 'locked', + unlockedPricePerShare, + unlockedVaultDecimals + }) + ).toBe(95_238_095_238_095_238_095n) + }) + + it('converts locked yvUSD shares into unlocked underlying assets', () => { + expect( + convertYvUsdVariantRawAmount({ + amount: 95_238_095_238_095_238_095n, + fromVariant: 'locked', + toVariant: 'unlocked', + unlockedPricePerShare, + unlockedVaultDecimals + }) + ).toBe(99_999_999n) + }) + + it('formats converted variant amounts with the destination decimals', () => { + expect( + convertYvUsdVariantAmountString({ + amount: '100', + fromVariant: 'unlocked', + toVariant: 'locked', + fromDecimals: 6, + toDecimals: 18, + unlockedPricePerShare, + unlockedVaultDecimals + }) + ).toBe('95.238095238095238095') + }) + + it('converts locked asset amounts into unlocked underlying amounts with the helper alias', () => { + expect( + convertYvUsdLockedAssetRawAmountToUnderlying({ + amount: 95_238_095_238_095_238_095n, + unlockedPricePerShare, + unlockedVaultDecimals + }) + ).toBe(99_999_999n) + }) + + it('converts unlocked underlying amounts into locked asset amounts with the helper alias', () => { + expect( + convertYvUsdUnderlyingRawAmountToLockedAsset({ + amount: 100_000_000n, + unlockedPricePerShare, + unlockedVaultDecimals + }) + ).toBe(95_238_095_238_095_238_095n) + }) + + it('converts locked price per share into underlying asset terms', () => { + expect( + convertYvUsdLockedPricePerShareToUnderlying({ + lockedPricePerShare: 1_100_000_000_000_000_000n, + unlockedPricePerShare, + unlockedVaultDecimals + }) + ).toBe(1_155_000n) + }) +}) + +describe('yvUSD locked withdraw display mode', () => { + it('uses underlying display mode when the helper is called with Enso enabled', () => { + expect(getYvUsdLockedWithdrawDisplayMode(true)).toBe('underlying') + }) + + it('keeps underlying display mode even when Enso is unavailable', () => { + expect(getYvUsdLockedWithdrawDisplayMode(false)).toBe('underlying') + }) +}) diff --git a/src/components/pages/vaults/utils/yvUsd.ts b/src/components/pages/vaults/utils/yvUsd.ts new file mode 100644 index 000000000..5523eed3f --- /dev/null +++ b/src/components/pages/vaults/utils/yvUsd.ts @@ -0,0 +1,284 @@ +import { getVaultView, type TKongVaultInput } from '@pages/vaults/domain/kongVaultSelectors' +import { toAddress } from '@shared/utils' +import { type Address, formatUnits, parseUnits } from 'viem' + +export const YVUSD_CHAIN_ID = 1 +export const YVUSD_UNLOCKED_ADDRESS = toAddress('0x696d02Db93291651ED510704c9b286841d506987') as Address +export const YVUSD_LOCKED_ADDRESS = toAddress('0xAaaFEa48472f77563961Cdb53291DEDfB46F9040') as Address +export const YVUSD_LOCKED_ZAP_ADDRESS = toAddress('0x7ba61c8e19414dcB8fe769a7Be63B508C8062bbA') as Address + +export const YVUSD_LOCKED_COOLDOWN_DAYS = 14 +export const YVUSD_WITHDRAW_WINDOW_DAYS = 5 +export const YVUSD_CUSTOM_RISK_SCORE = '3/5' +export const YVUSD_ANNOUNCEMENT_URL = '#' +export const YVUSD_LEARN_MORE_URL = '#' + +function getYvUsdAprServiceEndpoint(): string { + const configuredEndpoint = import.meta.env.VITE_YVUSD_APR_SERVICE_API?.trim().replace(/\/$/, '') + if (configuredEndpoint?.startsWith('/')) { + return configuredEndpoint + } + return '/api/yvusd/aprs' +} + +export const YVUSD_APR_SERVICE_ENDPOINT = getYvUsdAprServiceEndpoint() + +export const YVUSD_DESCRIPTION = + 'USD denominated, cross-chain, cross asset vault. Optionally lock shares to earn a higher yield by allowing the vault to take on longer duration positions.' + +export type TYvUsdVariant = 'locked' | 'unlocked' +export type TYvUsdLockedWithdrawDisplayMode = 'underlying' | 'shares' +export type TYvUsdRiskScoreItem = { + label: string + explanation: string + score?: number | string | null + isOverall?: boolean +} + +export const YVUSD_RISK_SCORE_ITEMS: TYvUsdRiskScoreItem[] = [ + { + label: 'Overall Risk Score', + score: YVUSD_CUSTOM_RISK_SCORE, + isOverall: true, + explanation: + 'yvUSD combines leverage looping, fixed-term and principal-token strategies, cross-chain capital routing, and a locked-share wrapper, so its risks are better described as a strategy stack rather than a single standard vault profile.' + }, + { + label: 'Leverage Looping', + explanation: + 'Some yvUSD strategies use leverage loops to amplify supply yield. That adds borrow-rate risk, deleveraging and liquidation-path risk, and dependence on collateral efficiency, market depth, and the health of the underlying lending venue.' + }, + { + label: 'Duration and PT Strategies', + explanation: + 'yvUSD can allocate into duration trades and Pendle principal-token strategies. Those positions depend on fixed-term market pricing, yield-curve assumptions, basis convergence into expiry, and the ability to exit or rebalance without meaningful slippage.' + }, + { + label: 'Cross-Chain Routing', + explanation: + 'Capital may be deployed to remote vaults and bridged back through native bridges such as CCTP. That introduces bridge availability risk, remote chain execution risk, settlement delays, and additional operational dependencies beyond a single-chain vault.' + } + // { + // label: 'Locked / Unlocked Dynamics', + // explanation: `Locked yvUSD earns the base yvUSD APR plus a locker bonus, but withdrawals require a ${YVUSD_LOCKED_COOLDOWN_DAYS}-day cooldown and must be completed within a ${YVUSD_WITHDRAW_WINDOW_DAYS}-day window. Unlocked depositors stay liquid but fund part of that bonus, so yield and liquidity can diverge between the two variants during stress or large flow changes.` + // }, + // { + // label: 'External Dependencies', + // explanation: + // 'The strategy set relies on external protocols, bridge rails, pricing assumptions, and active management across multiple venues. Smart contract failures, governance actions, liquidity shocks, or oracle issues in any of those layers can reduce returns or impair withdrawals.' + // } +] + +export function getYvUsdInfinifiPointsNote(variant?: TYvUsdVariant): string { + if (!variant) { + return 'This vault earns Infinifi points through the sIUSD looper strategy.' + } + + return `This ${variant} variant earns Infinifi points through the sIUSD looper strategy.` +} + +export function isYvUsdAddress(address?: string | null): boolean { + if (!address) return false + const normalized = toAddress(address) + return normalized === YVUSD_UNLOCKED_ADDRESS || normalized === YVUSD_LOCKED_ADDRESS +} + +export function isYvUsdVault(vault?: TKongVaultInput | null): boolean { + if (!vault) return false + return isYvUsdAddress(vault.address) +} + +export function getYvUsdAssetPrice(vault?: TKongVaultInput | null): number { + if (!vault) return 0 + const view = getVaultView(vault) + return view.tvl.price || 0 +} + +export function getYvUsdSharePrice(vault?: TKongVaultInput | null, fallbackAssetPrice = 0): number { + if (!vault) return 0 + const view = getVaultView(vault) + const assetPrice = view.tvl.price || fallbackAssetPrice + const pricePerShare = view.apr.pricePerShare.today || 0 + + if (assetPrice > 0 && pricePerShare > 0) { + return assetPrice * pricePerShare + } + + return assetPrice +} + +function normalizeWeightedValue(value: number | null | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return 0 + } + return value +} + +function isFiniteApy(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function getWeightedYvUsdApy({ + unlockedValue, + lockedValue, + unlockedApy, + lockedApy +}: { + unlockedValue?: number | null + lockedValue?: number | null + unlockedApy?: number | null + lockedApy?: number | null +}): number | null { + const normalizedUnlockedValue = normalizeWeightedValue(unlockedValue) + const normalizedLockedValue = normalizeWeightedValue(lockedValue) + const totalValue = normalizedUnlockedValue + normalizedLockedValue + + if (totalValue <= 0) { + return null + } + + let weightedApy = 0 + let hasFiniteApy = false + + if (isFiniteApy(unlockedApy)) { + weightedApy += normalizedUnlockedValue * unlockedApy + hasFiniteApy = true + } + + if (isFiniteApy(lockedApy)) { + weightedApy += normalizedLockedValue * lockedApy + hasFiniteApy = true + } + + if (!hasFiniteApy) { + return null + } + + return weightedApy / totalValue +} + +export function convertYvUsdVariantRawAmount({ + amount, + fromVariant, + toVariant, + unlockedPricePerShare, + unlockedVaultDecimals = 18 +}: { + amount: bigint + fromVariant: TYvUsdVariant + toVariant: TYvUsdVariant + unlockedPricePerShare: bigint + unlockedVaultDecimals?: number +}): bigint { + if (amount <= 0n || fromVariant === toVariant || unlockedPricePerShare <= 0n) { + return amount + } + + const vaultScale = 10n ** BigInt(unlockedVaultDecimals) + if (fromVariant === 'unlocked' && toVariant === 'locked') { + return (amount * vaultScale) / unlockedPricePerShare + } + + if (fromVariant === 'locked' && toVariant === 'unlocked') { + return (amount * unlockedPricePerShare) / vaultScale + } + + return amount +} + +export function convertYvUsdVariantAmountString({ + amount, + fromVariant, + toVariant, + fromDecimals, + toDecimals, + unlockedPricePerShare, + unlockedVaultDecimals = 18 +}: { + amount?: string + fromVariant: TYvUsdVariant + toVariant: TYvUsdVariant + fromDecimals: number + toDecimals: number + unlockedPricePerShare: bigint + unlockedVaultDecimals?: number +}): string | undefined { + if (amount === undefined) { + return undefined + } + + const trimmedAmount = amount.trim() + if (trimmedAmount.length === 0 || fromVariant === toVariant || unlockedPricePerShare <= 0n) { + return amount + } + + try { + const rawAmount = parseUnits(trimmedAmount, fromDecimals) + const convertedRawAmount = convertYvUsdVariantRawAmount({ + amount: rawAmount, + fromVariant, + toVariant, + unlockedPricePerShare, + unlockedVaultDecimals + }) + return formatUnits(convertedRawAmount, toDecimals) + } catch { + return amount + } +} + +export function convertYvUsdLockedAssetRawAmountToUnderlying({ + amount, + unlockedPricePerShare, + unlockedVaultDecimals = 18 +}: { + amount: bigint + unlockedPricePerShare: bigint + unlockedVaultDecimals?: number +}): bigint { + return convertYvUsdVariantRawAmount({ + amount, + fromVariant: 'locked', + toVariant: 'unlocked', + unlockedPricePerShare, + unlockedVaultDecimals + }) +} + +export function convertYvUsdUnderlyingRawAmountToLockedAsset({ + amount, + unlockedPricePerShare, + unlockedVaultDecimals = 18 +}: { + amount: bigint + unlockedPricePerShare: bigint + unlockedVaultDecimals?: number +}): bigint { + return convertYvUsdVariantRawAmount({ + amount, + fromVariant: 'unlocked', + toVariant: 'locked', + unlockedPricePerShare, + unlockedVaultDecimals + }) +} + +export function convertYvUsdLockedPricePerShareToUnderlying({ + lockedPricePerShare, + unlockedPricePerShare, + unlockedVaultDecimals = 18 +}: { + lockedPricePerShare: bigint + unlockedPricePerShare: bigint + unlockedVaultDecimals?: number +}): bigint { + if (lockedPricePerShare <= 0n || unlockedPricePerShare <= 0n) { + return 0n + } + + return (lockedPricePerShare * unlockedPricePerShare) / 10n ** BigInt(unlockedVaultDecimals) +} + +export function getYvUsdLockedWithdrawDisplayMode(_ensoEnabled?: boolean): TYvUsdLockedWithdrawDisplayMode { + return 'underlying' +} diff --git a/src/components/shared/contexts/useWallet.tsx b/src/components/shared/contexts/useWallet.tsx index 97076e233..afdf31c36 100644 --- a/src/components/shared/contexts/useWallet.tsx +++ b/src/components/shared/contexts/useWallet.tsx @@ -1,10 +1,21 @@ -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 { useYvUsdVaults } from '@pages/vaults/hooks/useYvUsdVaults' +import { getYvUsdSharePrice, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' 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 +29,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 +44,7 @@ type TWalletContext = { const defaultProps = { getToken: (): TToken => DEFAULT_ERC20, getBalance: (): TNormalizedBN => zeroNormalizedBN, + getVaultHoldingsUsd: (): number => 0, balances: {}, isLoading: true, cumulatedValueInV2Vaults: 0, @@ -48,17 +61,17 @@ export const WalletContextApp = memo(function WalletContextApp(props: { children: ReactElement shouldWorkOnTestnet?: boolean }): ReactElement { - const { vaults, isLoadingVaultList } = useYearn() + const { vaults, allVaults, isLoadingVaultList, getPrice } = useYearn() + const { unlockedVault: yvUsdUnlockedVault, lockedVault: yvUsdLockedVault } = useYvUsdVaults() 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 +88,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 +119,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 +131,68 @@ export const WalletContextApp = memo(function WalletContextApp(props: { [balances] ) + const yvUsdUnlockedSharePrice = getYvUsdSharePrice(yvUsdUnlockedVault) + const yvUsdLockedSharePrice = getYvUsdSharePrice(yvUsdLockedVault) + + 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)) { const normalizedAddress = toAddress(tokenAddress) + const canonicalAddress = getCanonicalHoldingsVaultAddress(normalizedAddress) + + if (normalizedAddress === YVUSD_UNLOCKED_ADDRESS || normalizedAddress === YVUSD_LOCKED_ADDRESS) { + const sharePrice = + normalizedAddress === YVUSD_UNLOCKED_ADDRESS ? yvUsdUnlockedSharePrice : yvUsdLockedSharePrice + const tokenValue = tokenData.value || tokenData.balance.normalized * sharePrice + cumulatedValueInV3Vaults += tokenValue + continue + } // 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 +202,7 @@ export const WalletContextApp = memo(function WalletContextApp(props: { } } return [cumulatedValueInV2Vaults, cumulatedValueInV3Vaults] - }, [balances, vaults]) + }, [allVaults, balances, getVaultHoldingsUsd, yvUsdLockedSharePrice, yvUsdUnlockedSharePrice]) /*************************************************************************** ** Setup and render the Context provider to use in the app. @@ -170,13 +211,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..9fb2d7256 100644 --- a/src/components/shared/contexts/useYearn.helper.tsx +++ b/src/components/shared/contexts/useYearn.helper.tsx @@ -8,22 +8,53 @@ import { getVaultToken, type TKongVault } from '@pages/vaults/domain/kongVaultSelectors' +import { getHoldingsAliasVaultAddress } from '@pages/vaults/domain/normalizeVault' +import { YVUSD_CHAIN_ID, YVUSD_LOCKED_ADDRESS, YVUSD_UNLOCKED_ADDRESS } from '@pages/vaults/utils/yvUsd' 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[] { @@ -80,7 +111,15 @@ export function useYearnTokens({ { chainID: 250, address: ETH_TOKEN_ADDRESS, decimals: 18, name: 'Fantom', symbol: 'FTM' }, { chainID: 8453, address: ETH_TOKEN_ADDRESS, decimals: 18, name: 'Ether', symbol: 'ETH' }, { chainID: 42161, address: ETH_TOKEN_ADDRESS, decimals: 18, name: 'Ether', symbol: 'ETH' }, - { chainID: 747474, address: ETH_TOKEN_ADDRESS, decimals: 18, name: 'Ether', symbol: 'ETH' } + { chainID: 747474, address: ETH_TOKEN_ADDRESS, decimals: 18, name: 'Ether', symbol: 'ETH' }, + { chainID: YVUSD_CHAIN_ID, address: YVUSD_UNLOCKED_ADDRESS, decimals: 18, name: 'yvUSD', symbol: 'yvUSD' }, + { + chainID: YVUSD_CHAIN_ID, + address: YVUSD_LOCKED_ADDRESS, + decimals: 18, + name: 'yvUSD (Locked)', + symbol: 'yvUSD' + } ] ) @@ -89,84 +128,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/contracts/abi/yvUsdLockedVault.abi.ts b/src/components/shared/contracts/abi/yvUsdLockedVault.abi.ts new file mode 100644 index 000000000..316d8ee07 --- /dev/null +++ b/src/components/shared/contracts/abi/yvUsdLockedVault.abi.ts @@ -0,0 +1,48 @@ +export const yvUsdLockedVaultAbi = [ + { + stateMutability: 'view', + type: 'function', + name: 'availableWithdrawLimit', + inputs: [{ name: '_owner', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'view', + type: 'function', + name: 'cooldownDuration', + inputs: [], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'view', + type: 'function', + name: 'withdrawalWindow', + inputs: [], + outputs: [{ name: '', type: 'uint256' }] + }, + { + stateMutability: 'view', + type: 'function', + name: 'getCooldownStatus', + inputs: [{ name: 'user', type: 'address' }], + outputs: [ + { name: 'cooldownEnd', type: 'uint256' }, + { name: 'windowEnd', type: 'uint256' }, + { name: 'shares', type: 'uint256' } + ] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'startCooldown', + inputs: [{ name: 'shares', type: 'uint256' }], + outputs: [] + }, + { + stateMutability: 'nonpayable', + type: 'function', + name: 'cancelCooldown', + inputs: [], + outputs: [] + } +] as const diff --git a/src/components/shared/contracts/abi/yvUsdLockedZap.abi.ts b/src/components/shared/contracts/abi/yvUsdLockedZap.abi.ts new file mode 100644 index 000000000..8a9582647 --- /dev/null +++ b/src/components/shared/contracts/abi/yvUsdLockedZap.abi.ts @@ -0,0 +1,112 @@ +export const yvUsdLockedZapAbi = [ + { + inputs: [{ internalType: 'address', name: '_lockedYvUSD', type: 'address' }], + stateMutability: 'nonpayable', + type: 'constructor' + }, + { + inputs: [{ internalType: 'address', name: 'token', type: 'address' }], + name: 'SafeERC20FailedOperation', + type: 'error' + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'user', type: 'address' }, + { indexed: true, internalType: 'uint256', name: 'assetAmount', type: 'uint256' }, + { indexed: true, internalType: 'uint256', name: 'lockedShares', type: 'uint256' } + ], + name: 'ZapIn', + type: 'event' + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: 'address', name: 'user', type: 'address' }, + { indexed: true, internalType: 'uint256', name: 'lockedShares', type: 'uint256' }, + { indexed: true, internalType: 'uint256', name: 'assetAmount', type: 'uint256' } + ], + name: 'ZapOut', + type: 'event' + }, + { + inputs: [], + name: 'asset', + outputs: [{ internalType: 'contract IERC20', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'lockedYvUSD', + outputs: [{ internalType: 'contract IERC4626', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [{ internalType: 'uint256', name: '_amount', type: 'uint256' }], + name: 'previewZapIn', + outputs: [{ internalType: 'uint256', name: 'lockedShares', type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [{ internalType: 'uint256', name: '_shares', type: 'uint256' }], + name: 'previewZapOut', + outputs: [{ internalType: 'uint256', name: 'assetAmount', type: 'uint256' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [], + name: 'yvUSD', + outputs: [{ internalType: 'contract IERC4626', name: '', type: 'address' }], + stateMutability: 'view', + type: 'function' + }, + { + inputs: [ + { internalType: 'uint256', name: '_amount', type: 'uint256' }, + { internalType: 'address', name: '_receiver', type: 'address' } + ], + name: 'zapIn', + outputs: [{ internalType: 'uint256', name: 'lockedShares', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [{ internalType: 'uint256', name: '_amount', type: 'uint256' }], + name: 'zapIn', + outputs: [{ internalType: 'uint256', name: 'lockedShares', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { internalType: 'uint256', name: '_shares', type: 'uint256' }, + { internalType: 'address', name: '_receiver', type: 'address' } + ], + name: 'zapOut', + outputs: [{ internalType: 'uint256', name: 'assetAmount', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [ + { internalType: 'uint256', name: '_shares', type: 'uint256' }, + { internalType: 'address', name: '_receiver', type: 'address' }, + { internalType: 'uint256', name: '_minAssetAmount', type: 'uint256' } + ], + name: 'zapOut', + outputs: [{ internalType: 'uint256', name: 'assetAmount', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + }, + { + inputs: [{ internalType: 'uint256', name: '_shares', type: 'uint256' }], + name: 'zapOut', + outputs: [{ internalType: 'uint256', name: 'assetAmount', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function' + } +] as const 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/icons/IconInfinifiPoints.tsx b/src/components/shared/icons/IconInfinifiPoints.tsx new file mode 100644 index 000000000..565185c03 --- /dev/null +++ b/src/components/shared/icons/IconInfinifiPoints.tsx @@ -0,0 +1,56 @@ +import type { ReactElement, SVGProps } from 'react' +import { useId } from 'react' + +export function IconInfinifiPoints(props: SVGProps): ReactElement { + const clipPathId = useId() + const paint0Id = useId() + const paint1Id = useId() + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/shared/icons/IconLock.tsx b/src/components/shared/icons/IconLock.tsx new file mode 100644 index 000000000..e1d3cc18a --- /dev/null +++ b/src/components/shared/icons/IconLock.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from 'react' + +type TProps = { + className?: string + size?: number +} + +export function IconLock({ className, size = 16 }: TProps): ReactElement { + return +} diff --git a/src/components/shared/icons/IconLockOpen.tsx b/src/components/shared/icons/IconLockOpen.tsx new file mode 100644 index 000000000..d520d0a6c --- /dev/null +++ b/src/components/shared/icons/IconLockOpen.tsx @@ -0,0 +1,19 @@ +import type { ReactElement } from 'react' + +type TProps = { + className?: string + size?: number +} + +export function IconLockOpen({ className, size = 16 }: TProps): ReactElement { + return ( + + ) +} diff --git a/src/components/shared/icons/IconSpectra.tsx b/src/components/shared/icons/IconSpectra.tsx index 8103d186c..f3232b98f 100644 --- a/src/components/shared/icons/IconSpectra.tsx +++ b/src/components/shared/icons/IconSpectra.tsx @@ -1,7 +1,9 @@ import type React from 'react' -import type { ReactElement } from 'react' +import { type ReactElement, useId } from 'react' export function IconSpectra(props: React.SVGProps): ReactElement { + const clipPathId = `spectra-clip-${useId().replace(/:/g, '')}` + return ( ): ReactElement } fill={'#00F99B'} /> - + ): ReactElement /> - + diff --git a/src/components/shared/types/notifications.ts b/src/components/shared/types/notifications.ts index fbc5ccc17..93824a85e 100644 --- a/src/components/shared/types/notifications.ts +++ b/src/components/shared/types/notifications.ts @@ -7,6 +7,8 @@ export type TNotificationType = | 'approve' | 'deposit' | 'withdraw' + | 'start cooldown' + | 'cancel cooldown' | 'zap' | 'crosschain zap' | 'withdraw zap' diff --git a/src/components/shared/utils/format.ts b/src/components/shared/utils/format.ts index 4c658b67f..d1bfcbce6 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 { @@ -586,7 +606,14 @@ function resolveApyFractionDigits(value: number): number { return resolveSignificantFractionDigits(value) } -export function formatTvlDisplay(value: number, options?: { locales?: string[] }): string { +export function formatTvlDisplay( + value: number, + options?: { + locales?: string[] + minimumFractionDigits?: number + maximumFractionDigits?: number + } +): string { if (value === Infinity || value === -Infinity) { return '$∞' } @@ -603,18 +630,23 @@ export function formatTvlDisplay(value: number, options?: { locales?: string[] } return `$${formatter.format(safeValue)}` } - let minimumFractionDigits = 0 - let maximumFractionDigits = 0 + let minimumFractionDigits = options?.minimumFractionDigits + let maximumFractionDigits = options?.maximumFractionDigits + + if (minimumFractionDigits === undefined || maximumFractionDigits === undefined) { + minimumFractionDigits = 0 + maximumFractionDigits = 0 - if (absValue < 1) { - minimumFractionDigits = 2 - maximumFractionDigits = 2 - } else if (absValue < 10) { - minimumFractionDigits = 2 - maximumFractionDigits = 2 - } else if (absValue < 100) { - minimumFractionDigits = 1 - maximumFractionDigits = 2 + if (absValue < 1) { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + } else if (absValue < 10) { + minimumFractionDigits = 2 + maximumFractionDigits = 2 + } else if (absValue < 100) { + minimumFractionDigits = 1 + maximumFractionDigits = 2 + } } const formatter = new Intl.NumberFormat(locales, { 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/schemas/yvUsdAprServiceSchema.ts b/src/components/shared/utils/schemas/yvUsdAprServiceSchema.ts new file mode 100644 index 000000000..925e47961 --- /dev/null +++ b/src/components/shared/utils/schemas/yvUsdAprServiceSchema.ts @@ -0,0 +1,66 @@ +import { addressSchema } from '@shared/types' +import * as z from 'zod' + +const numberSchema = z.union([z.number(), z.string()]).transform((value) => Number(value)) +const nullableNumberSchema = z + .union([z.number(), z.string(), z.null(), z.undefined()]) + .transform((value) => (value === null || value === undefined ? null : Number(value))) +const bigNumberishSchema = z.union([z.number(), z.string()]).transform((value) => String(value)) + +const yvUsdAprServiceComponentSchema = z + .object({ + label: z.string().optional().default('').catch(''), + apr: numberSchema.optional().default(0).catch(0), + apy: numberSchema.optional().default(0).catch(0), + source: z.string().optional().default('').catch(''), + meta: z.record(z.string(), z.unknown()).optional().default({}) + }) + .passthrough() + +const yvUsdAprServiceStrategyMetaSchema = z + .object({ + name: z.string().optional().default('').catch(''), + type: z.string().optional().default('').catch('') + }) + .passthrough() + +const yvUsdAprServiceStrategySchema = z + .object({ + address: addressSchema, + apr_source: z.string().optional().default('').catch(''), + offchain: z.record(z.string(), z.unknown()).optional().default({}), + meta: yvUsdAprServiceStrategyMetaSchema.optional().default({ name: '', type: '' }), + points: z.boolean().optional().default(false).catch(false), + apr_raw: bigNumberishSchema.optional().default('0').catch('0'), + net_apr_raw: bigNumberishSchema.optional().default('0').catch('0'), + weighted_apr_raw: bigNumberishSchema.optional().default('0').catch('0'), + weight: numberSchema.optional().default(0).catch(0), + debt: bigNumberishSchema.optional().default('0').catch('0') + }) + .passthrough() + +const yvUsdAprServiceMetaSchema = z + .object({ + strategies: z.array(yvUsdAprServiceStrategySchema).optional().default([]).catch([]), + asset: addressSchema.optional().catch(undefined) + }) + .passthrough() + +const yvUsdAprServiceVaultSchema = z + .object({ + name: z.string().optional().default('').catch(''), + symbol: z.string().optional().default('').catch(''), + address: addressSchema, + chain_id: numberSchema.optional().default(1).catch(1), + apr: nullableNumberSchema.optional().default(null).catch(null), + apy: nullableNumberSchema.optional().default(null).catch(null), + components: z.array(yvUsdAprServiceComponentSchema).optional().default([]).catch([]), + meta: yvUsdAprServiceMetaSchema.optional().default({ strategies: [] }) + }) + .passthrough() + +export const yvUsdAprServiceSchema = z.record(z.string(), yvUsdAprServiceVaultSchema) + +export type TYvUsdAprServiceResponse = z.infer +export type TYvUsdAprServiceVault = z.infer +export type TYvUsdAprServiceStrategy = 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: [] } }