diff --git a/components/entities/vault/overview/VaultOverviewBlockIRM.vue b/components/entities/vault/overview/VaultOverviewBlockIRM.vue index 06e0ecb06..67114dc4d 100644 --- a/components/entities/vault/overview/VaultOverviewBlockIRM.vue +++ b/components/entities/vault/overview/VaultOverviewBlockIRM.vue @@ -10,6 +10,7 @@ import { import { hasCollateralExposure } from '~/utils/vault/collateral-exposure' import { useVaultRegistry } from '~/composables/useVaultRegistry' import { eulerUtilsLensABI, eulerVaultLensABI } from '~/entities/euler/abis' +import { getVaultUtilizationDelta, getVaultUtilizationDeltaActionLabel } from '~/utils/vault-display' import annotationPlugin from 'chartjs-plugin-annotation' import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler, type ChartData, type ChartOptions } from 'chart.js' import { zeroAddress, formatUnits, type Address, type Abi } from 'viem' @@ -18,6 +19,7 @@ import { Line } from 'vue-chartjs' import { logWarn } from '~/utils/errorHandling' import { useModal } from '~/components/ui/composables/useModal' import { UiFootnoteModal } from '#components' +import { compactNumber } from '~/utils/string-utils' // Register Chart.js components ChartJS.register( @@ -271,6 +273,19 @@ const fetchAdaptiveBorrowAPY = async (wadPerSec: bigint): Promise } } +type FormattedUtilizationDelta = { + text: string +} + +const formatUtilizationDelta = (targetUtilizationPercent: number): FormattedUtilizationDelta | null => { + const delta = getVaultUtilizationDelta(vault, targetUtilizationPercent) + if (!delta) return null + + const amount = compactNumber(formatUnits(delta.amount, vault.asset.decimals), 2) + const action = getVaultUtilizationDeltaActionLabel(delta) + return action ? { text: `${action}: ${amount} ${vault.asset.symbol}` } : null +} + // Fetch interest rate model data const fetchIRMData = async (kinkFraction: number | null) => { if (!eulerLensAddresses.value?.vaultLens) { @@ -510,6 +525,11 @@ const renderChart = async () => { const value = typeof context.parsed.y === 'number' ? context.parsed.y.toFixed(2) : 'N/A' return `${context.dataset.label}: ${value}%` }, + footer: (context) => { + const targetUtilization = Number(context[0]?.label) + const delta = formatUtilizationDelta(targetUtilization) + return delta?.text ?? '' + }, labelColor: (context) => { return { borderColor: context.dataset.borderColor as string, diff --git a/tests/utils/vault-display.test.ts b/tests/utils/vault-display.test.ts index e1bee7ced..f3d0f6513 100644 --- a/tests/utils/vault-display.test.ts +++ b/tests/utils/vault-display.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { formatMarketAvailability } from '~/utils/vault-display' +import { formatMarketAvailability, getVaultUtilizationDelta, getVaultUtilizationDeltaActionLabel } from '~/utils/vault-display' describe('formatMarketAvailability', () => { it('formats unavailable, singular, and plural market counts', () => { @@ -9,3 +9,65 @@ describe('formatMarketAvailability', () => { expect(formatMarketAvailability(2)).toBe('Yes in 2 markets') }) }) + +describe('getVaultUtilizationDelta', () => { + const vault = { + totalAssets: 1000n, + totalBorrowed: 160n, + } + + it('returns the borrow amount needed to reach a higher utilization target', () => { + expect(getVaultUtilizationDelta(vault, 90)).toEqual({ + amount: 740n, + direction: 'borrow', + }) + }) + + it('returns the repay amount needed to reach a lower utilization target', () => { + expect(getVaultUtilizationDelta(vault, 10)).toEqual({ + amount: 60n, + direction: 'repay', + }) + }) + + it('returns none when the target matches current utilization', () => { + expect(getVaultUtilizationDelta(vault, 16)).toEqual({ + amount: 0n, + direction: 'none', + }) + }) + + it('clamps targets to the valid utilization range', () => { + expect(getVaultUtilizationDelta(vault, 120)).toEqual({ + amount: 840n, + direction: 'borrow', + }) + expect(getVaultUtilizationDelta(vault, -10)).toEqual({ + amount: 160n, + direction: 'repay', + }) + }) +}) + +describe('getVaultUtilizationDeltaActionLabel', () => { + it('labels higher target utilization as borrow', () => { + expect(getVaultUtilizationDeltaActionLabel({ + amount: 740n, + direction: 'borrow', + })).toBe('Borrow') + }) + + it('labels lower target utilization as repay', () => { + expect(getVaultUtilizationDeltaActionLabel({ + amount: 60n, + direction: 'repay', + })).toBe('Repay') + }) + + it('labels matching target utilization as no change', () => { + expect(getVaultUtilizationDeltaActionLabel({ + amount: 0n, + direction: 'none', + })).toBe('No change') + }) +}) diff --git a/utils/vault-display.ts b/utils/vault-display.ts index 832ebeee1..f51dabfa5 100644 --- a/utils/vault-display.ts +++ b/utils/vault-display.ts @@ -1,40 +1,85 @@ -export const getVaultSupplyApy = (vault: any): number => { - if (!vault) return 0 - if ('interestRates' in vault) return vault.interestRates.supplyAPY - if ('supplyApy1h' in vault) return vault.supplyApy1h ?? 0 - return 0 +const asBigint = (value: unknown): bigint | null => + typeof value === 'bigint' ? value : null + +const asRecord = (value: unknown): Record | null => + value && typeof value === 'object' ? value as Record : null + +export const getVaultSupplyApy = (vault: unknown): number => { + const source = asRecord(vault) + if (!source) return 0 + + const interestRates = asRecord(source.interestRates) + if (typeof interestRates?.supplyAPY === 'number') return interestRates.supplyAPY + return typeof source.supplyApy1h === 'number' ? source.supplyApy1h : 0 } -export const getVaultBorrowApy = (vault: any): number => { - if (!vault || !('interestRates' in vault)) return 0 - return vault.interestRates.borrowAPY +export const getVaultBorrowApy = (vault: unknown): number => { + const source = asRecord(vault) + const interestRates = asRecord(source?.interestRates) + return typeof interestRates?.borrowAPY === 'number' ? interestRates.borrowAPY : 0 } -export const getVaultAvailableLiquidity = (vault: any): bigint => { - if (!vault) return 0n - if (typeof vault.totalAssets === 'bigint' && typeof vault.totalBorrowed === 'bigint') { - const totalAssets = vault.totalAssets as bigint - const totalBorrowed = vault.totalBorrowed as bigint +export const getVaultAvailableLiquidity = (vault: unknown): bigint => { + const source = asRecord(vault) + if (!source) return 0n + + const totalAssets = asBigint(source.totalAssets) + const totalBorrowed = asBigint(source.totalBorrowed) + if (totalAssets !== null && totalBorrowed !== null) { return totalAssets >= totalBorrowed ? totalAssets - totalBorrowed : 0n } - if (typeof vault.availableLiquidity === 'bigint') return vault.availableLiquidity as bigint + const availableLiquidity = asBigint(source.availableLiquidity) + if (availableLiquidity !== null) return availableLiquidity return 0n } -export const getVaultUtilization = (vault: any): number => { - if (!vault) return 0 - const totalAssets = typeof vault.totalAssets === 'bigint' ? vault.totalAssets as bigint : 0n - const totalBorrowed = typeof vault.totalBorrowed === 'bigint' - ? vault.totalBorrowed as bigint - : typeof vault.borrow === 'bigint' - ? vault.borrow as bigint - : 0n +export const getVaultUtilization = (vault: unknown): number => { + const source = asRecord(vault) + if (!source) return 0 + + const totalAssets = asBigint(source.totalAssets) ?? 0n + const totalBorrowed = asBigint(source.totalBorrowed) ?? asBigint(source.borrow) ?? 0n if (totalAssets <= 0n || totalBorrowed <= 0n) return 0 return Number(((Number(totalBorrowed) / Number(totalAssets)) * 100).toFixed(2)) } +export type VaultUtilizationDelta = { + amount: bigint + direction: 'borrow' | 'repay' | 'none' +} + +export const getVaultUtilizationDeltaActionLabel = (delta: VaultUtilizationDelta | null | undefined): string | null => { + if (!delta) return null + if (delta.direction === 'borrow') return 'Borrow' + if (delta.direction === 'repay') return 'Repay' + return 'No change' +} + +export const getVaultUtilizationDelta = (vault: unknown, targetUtilizationPercent: number): VaultUtilizationDelta | null => { + const source = asRecord(vault) + if (!source || !Number.isFinite(targetUtilizationPercent)) return null + + const totalAssets = asBigint(source.totalAssets) ?? 0n + const totalBorrowed = asBigint(source.totalBorrowed) ?? asBigint(source.borrow) ?? 0n + + if (totalAssets <= 0n) return null + + const clampedPercent = Math.min(100, Math.max(0, targetUtilizationPercent)) + const percentScale = 10_000n + const utilizationUnits = BigInt(Math.round(clampedPercent * Number(percentScale))) + const targetBorrowed = totalAssets * utilizationUnits / (100n * percentScale) + + if (targetBorrowed > totalBorrowed) { + return { amount: targetBorrowed - totalBorrowed, direction: 'borrow' } + } + if (targetBorrowed < totalBorrowed) { + return { amount: totalBorrowed - targetBorrowed, direction: 'repay' } + } + return { amount: 0n, direction: 'none' } +} + export const formatMarketAvailability = (count: number) => { return count ? `Yes in ${count} ${count === 1 ? 'market' : 'markets'}` : 'No' }