Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2495bdd
feat: add vault open interest overview
Seranged Jun 25, 2026
b009aee
fix: polish open interest chart styling
Seranged Jun 25, 2026
9810fb7
fix: refine open interest graph layout
Seranged Jun 25, 2026
147be25
fix: move open interest into matrix view
Seranged Jun 25, 2026
d040f30
fix: collapse open interest matrix rows
Seranged Jun 25, 2026
987a01e
fix: collapse open interest backing rows
Seranged Jun 25, 2026
371a6b1
fix: center open interest show more
Seranged Jun 25, 2026
afed198
fix: add open interest token avatars
Seranged Jun 25, 2026
958a177
fix: remove collateral availability datapoint
Seranged Jun 25, 2026
7b8b4af
feat: group vault exposure surfaces
Seranged Jun 25, 2026
be8229e
fix: flatten single exposure groups
Seranged Jun 25, 2026
7a48633
fix: trim compact exposure rows
Seranged Jun 25, 2026
c7f20e2
fix: remove exposure group headers
Seranged Jun 25, 2026
8ab11c4
feat: add borrow open interest chart
Seranged Jun 25, 2026
2551897
fix: remove open interest timestamp
Seranged Jun 25, 2026
a6a1c25
fix: stack collateral exposure metrics
Seranged Jun 29, 2026
175c6fb
feat: add vault open interest chart
Seranged Jun 29, 2026
7ff50f1
fix: remove matrix open interest timestamp
Seranged Jun 29, 2026
fb0f1f9
feat: merge exposure views
Seranged Jun 29, 2026
7ddcf5c
fix: remove matrix open interest view
Seranged Jun 29, 2026
5969070
fix: guard live exposure unavailable states
Seranged Jun 29, 2026
943b855
fix: clarify exposure summaries
Seranged Jun 29, 2026
f7644db
fix: simplify lend collateral exposure
Seranged Jun 29, 2026
fa4d0e4
fix: clarify lend row backing asset
Seranged Jun 29, 2026
c4b609e
fix: align earn row exposure summary
Seranged Jun 30, 2026
b95d54c
fix: show live exposure in collateral modal
Seranged Jun 30, 2026
b1535fe
fix: preview collateral exposure modal on hover
Seranged Jun 30, 2026
22e623c
fix: share collateral open interest loading
Seranged Jun 30, 2026
80f2366
fix: remove earn vault combined exposure card
Seranged Jun 30, 2026
5058e10
change UI
kanvgupta Jun 30, 2026
429ea7e
fix: key exposure display rows by label
Seranged Jun 30, 2026
e9bc4bf
Merge pull request #657 from euler-xyz/pr624/review
Seranged Jun 30, 2026
04e01cf
fix: harden exposure display states
Seranged Jun 30, 2026
792e9e7
fix: show unavailable matrix exposure state
Seranged Jun 30, 2026
b5ea27f
fix: separate exposure market links
Seranged Jun 30, 2026
8c4facf
fix: hide unresolved exposure market sources
Seranged Jun 30, 2026
6b50ec0
fix: tighten exposure market label spacing
Seranged Jun 30, 2026
243c4e4
fix: align exposure market links inline
Seranged Jun 30, 2026
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
105 changes: 67 additions & 38 deletions components/entities/vault/VaultCollateralExposureModal.vue
Original file line number Diff line number Diff line change
@@ -1,31 +1,48 @@
<script setup lang="ts">
import type { SecuritizeCollateralVault, EVaultCollateral, EVault } from '@eulerxyz/euler-v2-sdk'
import type { SecuritizeCollateralVault, EVault } from '@eulerxyz/euler-v2-sdk'
import { useVaultRegistry } from '~/composables/useVaultRegistry'
import { formatNumber } from '~/utils/string-utils'
import { compactNumber, formatCompactUsdValue, formatNumber } from '~/utils/string-utils'
import { getCollateralExposureGroups, getCollateralExposurePairs } from '~/utils/vault/collateral-exposure'

const emits = defineEmits(['close'])
const router = useRouter()
const route = useRoute()
const { vault } = defineProps<{ vault: EVault }>()
const { get: registryGet } = useVaultRegistry()
const {
load: loadOpenInterest,
getOpenInterestForVault,
hasError: hasOpenInterestError,
isLoaded: isOpenInterestLoaded,
} = useCollateralOpenInterest()

const allCollateralPairs = computed(() => {
const pairs: Array<{
collateral: EVault | SecuritizeCollateralVault
ltv: EVaultCollateral
}> = []
const allCollateralPairs = computed(() =>
getCollateralExposurePairs(
vault,
addr => registryGet(addr)?.vault as EVault | SecuritizeCollateralVault | undefined,
),
)

vault.collaterals.forEach((ltv) => {
if (ltv.currentLiquidationLTV <= 0) return

const collateralEntry = registryGet(ltv.address)
if (collateralEntry) {
pairs.push({ collateral: collateralEntry.vault as EVault | SecuritizeCollateralVault, ltv })
}
})

return pairs.sort((a, b) => (b.ltv.borrowLTV > a.ltv.borrowLTV ? 1 : b.ltv.borrowLTV < a.ltv.borrowLTV ? -1 : 0))
})
const openInterestUsdByCollateral = computed(() => getOpenInterestForVault(vault.address))
const collateralGroups = computed(() =>
getCollateralExposureGroups(allCollateralPairs.value, openInterestUsdByCollateral.value),
)
const collateralPairs = computed(() => collateralGroups.value.flatMap(group => group.items))
const totalOpenInterestUsd = computed(() =>
collateralGroups.value.reduce((sum, group) => sum + group.openInterestUsd, 0),
)
const getPairOpenInterestUsd = (pair: { collateral: EVault | SecuritizeCollateralVault }) => {
const entry = Object.entries(openInterestUsdByCollateral.value)
.find(([address]) => address.toLowerCase() === pair.collateral.address.toLowerCase())
return entry?.[1] ?? 0
}
const hasLiveExposureData = computed(() => isOpenInterestLoaded.value && !hasOpenInterestError.value)
const formatExposurePercent = (valueUsd: number) =>
!hasLiveExposureData.value
? '-'
: totalOpenInterestUsd.value > 0 ? `${compactNumber(valueUsd / totalOpenInterestUsd.value * 100, 1, 0)}%` : '0%'
const formatLiveExposureUsd = (valueUsd: number) =>
hasLiveExposureData.value ? formatCompactUsdValue(valueUsd) : '-'

const formatTimeRemaining = (seconds: bigint): string => {
const days = Number(seconds) / 86400
Expand All @@ -44,43 +61,55 @@ const onCollateralClick = (address: string) => {
emits('close')
router.push({ path: `/borrow/${address}/${vault.address}`, query: { network: route.query.network } })
}

watchEffect(() => {
if (!vault.address) return
void loadOpenInterest()
})
</script>

<template>
<BaseModalWrapper
title="Collateral exposure"
title="Exposure"
@close="$emit('close')"
>
<div
v-if="allCollateralPairs.length > 0"
v-if="collateralGroups.length > 0"
class="flex flex-col gap-12"
>
<p class="text-p3 text-content-secondary mb-4">
Deposits in this vault can be borrowed.
Make sure you're comfortable accepting the collaterals listed below before supplying.
Make sure you're comfortable with the exposure assets and vaults listed below before supplying.
</p>
<div
v-for="pair in allCollateralPairs"
v-for="pair in collateralPairs"
:key="pair.collateral.address"
class="bg-surface rounded-12 text-content-primary block no-underline cursor-pointer hover:bg-card-hover transition-colors"
class="cursor-pointer rounded-12 border border-line-subtle bg-surface p-16 text-content-primary transition-colors hover:bg-card-hover"
@click="onCollateralClick(pair.collateral.address)"
>
<div class="px-16 pt-16 pb-12 border-b border-line-subtle">
<div class="min-w-0">
<VaultLabelsAndAssets
class="min-w-0"
:vault="pair.collateral"
:assets="[pair.collateral.asset]"
/>
<VaultTypeBadges
class="mt-8 w-full justify-end"
:vault="pair.collateral"
summary-only
@click.stop.prevent
/>
</div>
<div class="min-w-0">
<VaultLabelsAndAssets
class="min-w-0"
:vault="pair.collateral"
:assets="[pair.collateral.asset]"
/>
<VaultTypeBadges
class="mt-8 w-full justify-end"
:vault="pair.collateral"
summary-only
@click.stop.prevent
/>
</div>
<div class="flex flex-col gap-12 px-16 pt-12 pb-16">
<div class="flex flex-col gap-12 pt-12">
<VaultOverviewLabelValue
label="Live exposure"
orientation="horizontal"
>
<span class="flex items-center gap-4">
{{ formatLiveExposureUsd(getPairOpenInterestUsd(pair)) }}
<span class="text-content-secondary">({{ formatExposurePercent(getPairOpenInterestUsd(pair)) }})</span>
</span>
</VaultOverviewLabelValue>
<VaultOverviewLabelValue
label="Max LTV"
orientation="horizontal"
Expand Down
169 changes: 157 additions & 12 deletions components/entities/vault/VaultEarnItem.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,39 @@
<script setup lang="ts">
import { computeSupplyApyBreakdown, type EulerEarn } from '@eulerxyz/euler-v2-sdk'
import { computeSupplyApyBreakdown, isEVault, type EVault, type EulerEarn, type EulerEarnStrategyInfo, type SecuritizeCollateralVault } from '@eulerxyz/euler-v2-sdk'

import { formatAssetValue } from '~/utils/sdk-prices'
import { useEulerProductOfVault, useEulerEntitiesOfEarnVault } from '~/composables/useEulerLabels'
import { isVaultRecentlyAdded, getEarnVaultDescription } from '~/utils/eulerLabelsUtils'
import { getEarnVaultDescription, getProductByVault, getProductKeyByVault, isVaultRecentlyAdded } from '~/utils/eulerLabelsUtils'
import { getEulerLabelEntityLogo } from '~/entities/euler/labels'
import { getVaultIntrinsicApyInfo } from '~/utils/vault-intrinsic-apy'
import { isVaultBlockedByCountry } from '~/composables/useGeoBlock'
import { formatNumber, formatCompactUsdValue } from '~/utils/string-utils'
import BaseLoadableContent from '~/components/base/BaseLoadableContent.vue'
import { VaultSupplyApyModal, UiModalPreviewTrigger } from '#components'
import {
getCollateralExposureGroups,
getCollateralExposurePairs,
} from '~/utils/vault/collateral-exposure'
import {
buildAllocatedVaultExposureDisplayItems,
hasMissingUtilizedExposureSplit,
mergeVaultExposureDisplayItems,
type ExposureValueState,
} from '~/utils/vault/exposure-display'

const { isConnected } = useWagmi()
const { vault } = defineProps<{ vault: EulerEarn }>()
const route = useRoute()
const product = useEulerProductOfVault(vault.address)
const { enableEntityBranding } = useDeployConfig()
const { isEarnVaultOwnerVerified } = useVaults()
const { isVerifiedVault } = useVaultRegistry()
const { get: registryGet, isVerifiedVault } = useVaultRegistry()
const {
load: loadOpenInterest,
getOpenInterestForVault,
hasError: hasOpenInterestError,
isLoaded: isOpenInterestLoaded,
} = useCollateralOpenInterest()
const entities = useEulerEntitiesOfEarnVault(vault)
const isOwnerVerified = computed(() => isEarnVaultOwnerVerified(vault))
const entityName = computed(() => {
Expand All @@ -34,6 +51,13 @@ const { settings } = useUserSettings()
const enableIntrinsicApy = computed(() => settings.value.enableIntrinsicApy)
const { viewer } = useApyVisibility()
const { hasSupplyRewards, getSupplyRewardCampaigns } = useRewardsApy()
interface StrategyAllocationUsd {
valueUsd: number
valueState: ExposureValueState
}

const strategyAllocationUsdByAddress = ref<Map<string, StrategyAllocationUsd>>(new Map())
let strategyAllocationLoadId = 0

const balance = computed(() =>
getBalance(vault.asset.address as `0x${string}`),
Expand All @@ -58,6 +82,116 @@ const isRecentlyAdded = computed(() => isVaultRecentlyAdded(vault.address))
const isUnverified = computed(() => !isVerifiedVault(vault.address))
const displayName = computed(() => product.name || vault.shares.name)
const description = computed(() => getEarnVaultDescription(vault.address))
const getStrategyVault = (strategy: EulerEarnStrategyInfo): EVault | undefined => {
if (strategy.vault && isEVault(strategy.vault)) return strategy.vault as EVault
const entry = registryGet(strategy.address)
return entry?.vault && isEVault(entry.vault) ? entry.vault as EVault : undefined
}
const getStrategyMarketSource = (strategyVault: EVault) => {
const marketKey = getProductKeyByVault(strategyVault.address)
if (!marketKey) return undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking maintainability note: this same getStrategyMarketSource helper is now repeated across the Earn row, Earn stats, and Earn exposure components. The helper also drops the source entirely when getProductKeyByVault() has no route key, even though the exposure sources modal can render a non-linked source label. If suppressing unlinked sources is intentional, fine; otherwise consider extracting a shared helper that always returns the label and conditionally adds to, with a small linked-vs-unlinked strategy test. Neat little branch, but it would like a single home.


const marketName = getProductByVault(strategyVault.address).name || strategyVault.asset.symbol
return {
label: marketName,
to: {
name: 'explore-market',
params: { market: marketKey },
query: { network: route.query.network },
},
}
}
const hasLiveExposureData = computed(() => isOpenInterestLoaded.value && !hasOpenInterestError.value)
const isStrategyAllocationUsdLoaded = computed(() =>
vault.strategies.every((strategy) => {
const strategyVault = getStrategyVault(strategy)
return !strategyVault || strategyAllocationUsdByAddress.value.has(strategy.address.toLowerCase())
}),
)
const hasUnavailableStrategyAllocationUsd = computed(() =>
[...strategyAllocationUsdByAddress.value.values()].some(allocation => allocation.valueState === 'unavailable'),
)
const getStrategyCollateralGroups = (strategyVault: EVault) =>
getCollateralExposureGroups(
getCollateralExposurePairs(
strategyVault,
addr => registryGet(addr)?.vault as EVault | SecuritizeCollateralVault | undefined,
),
getOpenInterestForVault(strategyVault.address),
)
const hasUnavailableExposureSplit = computed(() => {
if (!hasLiveExposureData.value || !isStrategyAllocationUsdLoaded.value) return false

return vault.strategies.some((strategy) => {
const strategyVault = getStrategyVault(strategy)
if (!strategyVault) return false

const allocation = strategyAllocationUsdByAddress.value.get(strategy.address.toLowerCase())
if (!allocation || allocation.valueState !== 'ready' || allocation.valueUsd <= 0) return false

return hasMissingUtilizedExposureSplit(getStrategyCollateralGroups(strategyVault), strategyVault.utilization)
})
})
const exposureValueState = computed<ExposureValueState>(() => {
if (!isStrategyAllocationUsdLoaded.value) return 'loading'
if (hasOpenInterestError.value) return 'unavailable'
if (hasUnavailableStrategyAllocationUsd.value) return 'unavailable'
if (hasUnavailableExposureSplit.value) return 'unavailable'
if (!isOpenInterestLoaded.value) return 'loading'
return 'ready'
})
const strategyExposureItems = computed(() =>
exposureValueState.value === 'ready'
? vault.strategies.flatMap((strategy) => {
const strategyVault = getStrategyVault(strategy)
if (!strategyVault) return []

const allocation = strategyAllocationUsdByAddress.value.get(strategy.address.toLowerCase())
if (!allocation) return []

return buildAllocatedVaultExposureDisplayItems({
collateralGroups: getStrategyCollateralGroups(strategyVault),
totalExposureUsd: allocation.valueUsd,
idleAsset: strategyVault.asset,
utilization: strategyVault.utilization,
idleSource: getStrategyMarketSource(strategyVault),
})
})
: [],
)
const exposureDisplayItems = computed(() =>
mergeVaultExposureDisplayItems(strategyExposureItems.value),
)

watchEffect(() => {
if (!vault.strategies.length) return
void loadOpenInterest()
})

watchEffect(async () => {
const loadId = ++strategyAllocationLoadId
const results = await Promise.all(vault.strategies.map(async (strategy) => {
const strategyVault = getStrategyVault(strategy)
if (!strategyVault) return null

const price = await formatAssetValue(strategy.allocatedAssets, strategyVault, 'off-chain')
return {
address: strategy.address.toLowerCase(),
valueUsd: price.hasPrice ? price.usdValue : 0,
valueState: price.hasPrice ? 'ready' : 'unavailable',
}
}))
if (loadId !== strategyAllocationLoadId) return

strategyAllocationUsdByAddress.value = new Map(
results
.filter((result): result is { address: string } & StrategyAllocationUsd => Boolean(result))
.map(result => [result.address, {
valueUsd: result.valueUsd,
valueState: result.valueState,
}]),
)
})

const prices = ref<{ totalSupply: string, liquidity: string, walletBalance: string }>({
totalSupply: '-',
Expand All @@ -84,7 +218,7 @@ const statsGridCols = computed(() => {
if (enableEntityBranding) cols.push('1fr')
cols.push('1fr') // Total supply
cols.push('1fr') // Available liquidity
cols.push('1fr') // Strategies
cols.push('1fr') // Exposure
if (isConnected.value) cols.push('1fr') // In wallet
return cols.join(' ')
})
Expand Down Expand Up @@ -270,16 +404,21 @@ const supplyApyModalData = computed(() => ({
:class="isConnected ? 'items-center' : 'items-end text-right'"
>
<div class="text-content-tertiary text-p3 mb-4">
Allocates into
Exposure
</div>
<div
class="text-p2 text-content-primary"
class="flex min-w-0 items-center justify-end"
data-id="data-point"
:data-key="vault.address.toLowerCase()"
data-field="allocates-into"
:data-value="vault.strategies.length"
data-field="exposure"
:data-value="exposureDisplayItems.map(item => item.label ?? item.asset.symbol).join(',')"
>
{{ vault.strategies.length }} {{ vault.strategies.length === 1 ? 'strategy' : 'strategies' }}
<VaultExposureSummary
:items="exposureDisplayItems"
:value-state="exposureValueState"
:max-visible="5"
avatar-size="20"
/>
</div>
</div>
<div class="flex flex-col flex-1 items-end text-right mobile:!hidden">
Expand Down Expand Up @@ -337,10 +476,16 @@ const supplyApyModalData = computed(() => ({
</div>
<div class="flex w-full justify-between">
<div class="text-content-tertiary text-p3">
Allocates into
Exposure
</div>
<div class="text-p2 text-content-primary">
{{ vault.strategies.length }} {{ vault.strategies.length === 1 ? 'strategy' : 'strategies' }}
<div class="flex min-w-0 items-center justify-end text-right">
<VaultExposureSummary
:items="exposureDisplayItems"
:value-state="exposureValueState"
:max-visible="5"
avatar-size="20"
placement="top-start"
/>
</div>
</div>
<div
Expand Down
Loading