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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions components/entities/vault/overview/VaultOverviewBlockIRM.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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(
Expand Down Expand Up @@ -271,6 +273,19 @@ const fetchAdaptiveBorrowAPY = async (wadPerSec: bigint): Promise<number | null>
}
}

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) {
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 63 additions & 1 deletion tests/utils/vault-display.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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')
})
})
89 changes: 67 additions & 22 deletions utils/vault-display.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null =>
value && typeof value === 'object' ? value as Record<string, unknown> : 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
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard APY reads against NaN/Infinity values.

typeof x === 'number' still accepts non-finite numbers. Returning those can propagate invalid values into chart/UI math. Prefer Number.isFinite(...) in both APY getters.

Proposed patch
 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
+  if (Number.isFinite(interestRates?.supplyAPY)) return interestRates!.supplyAPY as number
+  return Number.isFinite(source.supplyApy1h) ? source.supplyApy1h as number : 0
 }
@@
 export const getVaultBorrowApy = (vault: unknown): number => {
   const source = asRecord(vault)
   const interestRates = asRecord(source?.interestRates)
-  return typeof interestRates?.borrowAPY === 'number' ? interestRates.borrowAPY : 0
+  return Number.isFinite(interestRates?.borrowAPY) ? interestRates!.borrowAPY as number : 0
 }

Also applies to: 19-19

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/vault-display.ts` around lines 12 - 13, The APY getter functions use
typeof checks that incorrectly accept NaN and Infinity values since both typeof
NaN and typeof Infinity return 'number'. Replace the typeof checks for both
interestRates?.supplyAPY and source.supplyApy1h with Number.isFinite() to ensure
only valid finite numbers are accepted. This prevents invalid values from
propagating into UI math and chart calculations, falling back to 0 for any
non-finite or missing values.

}

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'
}