Skip to content
Open
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
15 changes: 15 additions & 0 deletions composables/useEulerAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ const isPositionsLoaded = ref(false)
const isDepositsLoading = ref(true)
const isDepositsLoaded = ref(false)
const isShowAllPositions = ref(false)
// True when the most recent portfolio fetch threw, as opposed to succeeding
// with partial per-asset pricing issues. Surfaced to the UI so a failed load
// renders an error state instead of formatted zeros — see `hasPortfolioLoadError`.
const hasPositionsFetchError = ref(false)

// Transparent layer overlay: when a non-zero batch layer is active, the
// simulated portfolio is served for both the visible and all-positions views,
Expand Down Expand Up @@ -101,6 +105,7 @@ export const useEulerAccount = () => {
visiblePortfolio.value = undefined
allPortfolio.value = undefined
portfolioDiagnostics.value = []
hasPositionsFetchError.value = false
isPositionsLoaded.value = false
isPositionsLoading.value = true
isDepositsLoaded.value = false
Expand All @@ -125,6 +130,7 @@ export const useEulerAccount = () => {
visiblePortfolio.value = undefined
allPortfolio.value = undefined
portfolioDiagnostics.value = []
hasPositionsFetchError.value = false
markLoaded()
return
}
Expand Down Expand Up @@ -157,11 +163,13 @@ export const useEulerAccount = () => {
allPortfolio.value = nextAllPortfolio
visiblePortfolio.value = nextVisiblePortfolio
portfolioDiagnostics.value = fetched.errors
hasPositionsFetchError.value = false
markLoaded()
}
catch (error) {
if (positionGuard.isStale(gen)) return
logWarn('useEulerAccount/fetchAndUpdatePortfolio', error)
hasPositionsFetchError.value = true
portfolioDiagnostics.value = [{
code: 'SOURCE_UNAVAILABLE',
severity: 'error',
Expand Down Expand Up @@ -227,6 +235,12 @@ export const useEulerAccount = () => {
startWatchers()
onScopeDispose(releaseWatchers)

// A portfolio fetch failed AND we have no (stale) portfolio to fall back on.
// Gating on the absence of data means a transient background-refresh failure
// keeps the last-good figures on screen, while a genuine cold-load failure
// lets the UI show an error instead of misleading $0.00 totals.
const hasPortfolioLoadError = computed(() => hasPositionsFetchError.value && !portfolio.value)

const portfolioRoe = computed(() => portfolio.value?.roe ?? 0)
const portfolioNetApy = computed(() => portfolio.value?.netApy ?? 0)
const totalSuppliedValue = computed(() => usdWadToNumber(portfolio.value?.totalSuppliedValueUsd))
Expand Down Expand Up @@ -276,6 +290,7 @@ export const useEulerAccount = () => {
return {
portfolio,
portfolioDiagnostics,
hasPortfolioLoadError,
borrowPositions,
depositPositions,
removedBorrowPositions,
Expand Down
46 changes: 39 additions & 7 deletions pages/portfolio.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
isPositionsLoaded,
isShowAllPositions,
refreshAllPositions,
hasPortfolioLoadError,
} = useEulerAccount()
const { refresh: refreshFreshAccount } = useFreshAccount()
const { rewards } = useSdkRewards()
Expand Down Expand Up @@ -134,17 +135,23 @@ watch([() => route.name, showMigrationTab, hasMigrationLoaded, isMigrationLoadin
})

const portfolioNetApyDisplay = computed(() =>
Number.isFinite(portfolioNetApy.value) ? `${formatNumber(portfolioNetApy.value)}%` : '-',
!hasPortfolioLoadError.value && Number.isFinite(portfolioNetApy.value)
? `${formatNumber(portfolioNetApy.value)}%`
: '-',
)
const portfolioRoeDisplay = computed(() =>
Number.isFinite(portfolioRoe.value) ? `${formatNumber(portfolioRoe.value)}%` : '-',
!hasPortfolioLoadError.value && Number.isFinite(portfolioRoe.value)
? `${formatNumber(portfolioRoe.value)}%`
: '-',
)
const totalSuppliedDisplay = computed(() => {
if (hasPortfolioLoadError.value) return '—'
const { total, hasMissingPrices } = totalSuppliedValueInfo.value
if (total === 0 && hasMissingPrices) return '—'
return formatCompactUsdValue(total)
})
const totalBorrowedDisplay = computed(() => {
if (hasPortfolioLoadError.value) return '—'
const { total, hasMissingPrices } = totalBorrowedValueInfo.value
if (total === 0 && hasMissingPrices) return '—'
return formatCompactUsdValue(total)
Expand All @@ -153,6 +160,7 @@ const netAssetValueInfo = computed(() => {
return netAssetMarketValueInfo.value
})
const netAssetValueDisplay = computed(() => {
if (hasPortfolioLoadError.value) return '—'
const { total, hasMissingPrices } = netAssetValueInfo.value
if (total === 0 && hasMissingPrices) return '—'
return formatCompactUsdValue(total)
Expand All @@ -173,6 +181,10 @@ const updatePositions = async (
})
}

const retryPortfolioLoad = () => {
void updatePositions({ portfolioSource: 'fresh', preemptPortfolio: true })
}

onActivated(async () => {
await updateBalances()
updatePositions()
Expand Down Expand Up @@ -218,6 +230,26 @@ watch(showAllLabelEntries, (showAll) => {

<PortfolioRampingBanner />

<div
v-if="hasPortfolioLoadError"
class="flex items-center gap-8 rounded-12 p-12 mx-16 bg-error-100"
>
<SvgIcon
name="warning"
class="!w-20 !h-20 text-error-500 shrink-0"
/>
<span class="text-p4 flex-1 text-error-500">
We couldn't load your portfolio. Your funds are safe on-chain — this is usually temporary.
</span>
<button
type="button"
class="shrink-0 text-p4 font-medium text-error-500 hover:text-error-500/70 transition-colors"
@click="retryPortfolioLoad"
>
Retry
</button>
</div>

<div class="flex flex-col gap-16 mx-16 laptop:flex-row laptop:items-stretch">
<div class="flex flex-col gap-16 p-16 rounded-12 border border-line-default bg-card shadow-card laptop:flex-1">
<div class="text-h4 text-content-primary">
Expand All @@ -240,7 +272,7 @@ watch(showAllLabelEntries, (showAll) => {
data-id="data-point"
:data-key="spyAddress || address"
data-field="portfolio-net-apy"
:data-value="Number.isFinite(portfolioNetApy) ? portfolioNetApy : '-'"
:data-value="hasPortfolioLoadError ? '-' : (Number.isFinite(portfolioNetApy) ? portfolioNetApy : '-')"
>
{{ portfolioNetApyDisplay }}
</div>
Expand All @@ -263,7 +295,7 @@ watch(showAllLabelEntries, (showAll) => {
data-id="data-point"
:data-key="spyAddress || address"
data-field="portfolio-roe"
:data-value="Number.isFinite(portfolioRoe) ? portfolioRoe : '-'"
:data-value="hasPortfolioLoadError ? '-' : (Number.isFinite(portfolioRoe) ? portfolioRoe : '-')"
>
{{ portfolioRoeDisplay }}
</div>
Expand Down Expand Up @@ -291,7 +323,7 @@ watch(showAllLabelEntries, (showAll) => {
data-id="data-point"
:data-key="spyAddress || address"
data-field="portfolio-total-supplied"
:data-value="totalSuppliedValueInfo.hasMissingPrices ? totalSuppliedDisplay : totalSuppliedValueInfo.total"
:data-value="hasPortfolioLoadError ? '—' : (totalSuppliedValueInfo.hasMissingPrices ? totalSuppliedDisplay : totalSuppliedValueInfo.total)"
>
{{ totalSuppliedDisplay }}
</div>
Expand All @@ -314,7 +346,7 @@ watch(showAllLabelEntries, (showAll) => {
data-id="data-point"
:data-key="spyAddress || address"
data-field="portfolio-total-borrowed"
:data-value="totalBorrowedValueInfo.hasMissingPrices ? totalBorrowedDisplay : totalBorrowedValueInfo.total"
:data-value="hasPortfolioLoadError ? '—' : (totalBorrowedValueInfo.hasMissingPrices ? totalBorrowedDisplay : totalBorrowedValueInfo.total)"
>
{{ totalBorrowedDisplay }}
</div>
Expand All @@ -337,7 +369,7 @@ watch(showAllLabelEntries, (showAll) => {
data-id="data-point"
:data-key="spyAddress || address"
data-field="portfolio-net-asset-value"
:data-value="netAssetValueInfo.hasMissingPrices ? netAssetValueDisplay : netAssetValueInfo.total"
:data-value="hasPortfolioLoadError ? '—' : (netAssetValueInfo.hasMissingPrices ? netAssetValueDisplay : netAssetValueInfo.total)"
>
{{ netAssetValueDisplay }}
</div>
Expand Down
114 changes: 90 additions & 24 deletions tests/composables/useEulerAccount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,47 @@ import { effectScope, nextTick, ref, type EffectScope } from 'vue'

const owner = '0x1000000000000000000000000000000000000000'

const importUseEulerAccount = async () => {
const fetchedPortfolio = {
account: { owner },
borrows: ['all-borrow'],
savings: ['all-saving'],
totalSuppliedValueUsd: 100,
totalBorrowedValueUsd: 25,
netAssetValueUsd: 75,
roe: 3,
netApy: 2,
}

const visiblePortfolio = {
account: { owner },
borrows: ['visible-borrow'],
savings: [],
totalSuppliedValueUsd: 40,
totalBorrowedValueUsd: 10,
netAssetValueUsd: 30,
roe: 1,
netApy: 0.5,
}

const portfolioResponse = () => ({
errors: [],
result: fetchedPortfolio,
})

const importUseEulerAccount = async (
{ failFetch = false, fetchPortfolioImpl }: {
failFetch?: boolean
fetchPortfolioImpl?: () => Promise<ReturnType<typeof portfolioResponse>>
} = {},
) => {
vi.resetModules()

const fetchPortfolio = vi.fn(async () => ({
errors: [],
result: {
account: { owner },
borrows: ['all-borrow'],
savings: ['all-saving'],
totalSuppliedValueUsd: 100,
totalBorrowedValueUsd: 25,
netAssetValueUsd: 75,
roe: 3,
netApy: 2,
},
}))
const buildPortfolio = vi.fn(() => ({
account: { owner },
borrows: ['visible-borrow'],
savings: [],
totalSuppliedValueUsd: 40,
totalBorrowedValueUsd: 10,
netAssetValueUsd: 30,
roe: 1,
netApy: 0.5,
}))
const fetchPortfolio = vi.fn(async () => {
if (fetchPortfolioImpl) return fetchPortfolioImpl()
if (failFetch) throw new Error('portfolio source unavailable')
return portfolioResponse()
})
const buildPortfolio = vi.fn(() => visiblePortfolio)
const sdk = {
portfolioService: {
fetchPortfolio,
Expand Down Expand Up @@ -122,4 +137,55 @@ describe('useEulerAccount', () => {
expect(account?.depositPositions.value).toEqual(['all-saving'])
expect(account?.totalSuppliedValue.value).toBe(100)
})

it('flags a load error (rather than showing zeros) when the fetch fails with no data to fall back on', async () => {
const { useEulerAccount, fetchPortfolio } = await importUseEulerAccount({ failFetch: true })

let account: ReturnType<typeof useEulerAccount> | undefined
scope = effectScope()
scope.run(() => {
account = useEulerAccount()
})

await vi.waitFor(() => expect(fetchPortfolio).toHaveBeenCalled())
await vi.waitFor(() => expect(account?.hasPortfolioLoadError.value).toBe(true))

// No portfolio is present, so the totals are zero — the error flag is what
// lets the page render an error state instead of a misleading $0.00.
expect(account?.portfolio.value).toBeUndefined()
expect(account?.totalSuppliedValue.value).toBe(0)
expect(account?.portfolioDiagnostics.value.some(issue => issue.severity === 'error')).toBe(true)
})

it('keeps the load error visible until a retry confirms new data', async () => {
let resolveRetry: ((value: ReturnType<typeof portfolioResponse>) => void) | undefined
const fetchPortfolioImpl = vi.fn()
.mockRejectedValueOnce(new Error('portfolio source unavailable'))
.mockImplementationOnce(() => new Promise<ReturnType<typeof portfolioResponse>>((resolve) => {
resolveRetry = resolve
}))

const { useEulerAccount, fetchPortfolio } = await importUseEulerAccount({ fetchPortfolioImpl })

let account: ReturnType<typeof useEulerAccount> | undefined
scope = effectScope()
scope.run(() => {
account = useEulerAccount()
})

await vi.waitFor(() => expect(fetchPortfolio).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(account?.hasPortfolioLoadError.value).toBe(true))

const retry = account!.refreshAllPositions(undefined, owner, { preempt: true })
await vi.waitFor(() => expect(fetchPortfolio).toHaveBeenCalledTimes(2))

expect(account?.portfolio.value).toBeUndefined()
expect(account?.hasPortfolioLoadError.value).toBe(true)

resolveRetry?.(portfolioResponse())
await retry

expect(account?.portfolio.value).toEqual(visiblePortfolio)
expect(account?.hasPortfolioLoadError.value).toBe(false)
})
})
Loading