From 6a970c665e54f247a0cd1d0ac96dc019ebbe00b5 Mon Sep 17 00:00:00 2001
From: Seranged <80223622+Seranged@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:49:36 +0100
Subject: [PATCH 1/2] fix(portfolio): show an error state instead of $0.00 when
the portfolio fetch fails
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When `fetchAndUpdatePortfolio` threw, the catch block recorded a diagnostic but
still called `markLoaded()`, leaving the portfolio refs `undefined`. The page's
display logic only substitutes "—" when positions exist with missing prices, so
an outright load failure fell through to formatted zeros: "$0.00 Supplied /
$0.00 Borrowed / $0.00 Net Worth". For an account with real deposits and borrows
this is indistinguishable from an empty account, and the recorded diagnostics
were consumed nowhere in the UI.
Track an explicit `hasPositionsFetchError` flag, set on a thrown fetch and
cleared on every fresh attempt, and expose `hasPortfolioLoadError` — true only
when a fetch failed AND there is no (stale) portfolio to fall back on, so a
transient background-refresh failure keeps the last-good figures on screen while
a genuine cold-load failure surfaces an error. The portfolio page renders a
dismissable-free error banner with a Retry action and shows "—"/"-" for the
totals and performance figures instead of zeros. Mirrors the existing
"Position data is incomplete" handling on the per-position page.
Adds a unit test covering the failed-fetch path.
---
composables/useEulerAccount.ts | 14 +++++++
pages/portfolio.vue | 46 +++++++++++++++++----
tests/composables/useEulerAccount.test.ts | 50 ++++++++++++++++-------
3 files changed, 89 insertions(+), 21 deletions(-)
diff --git a/composables/useEulerAccount.ts b/composables/useEulerAccount.ts
index ca024f8d1..2f5dd033a 100644
--- a/composables/useEulerAccount.ts
+++ b/composables/useEulerAccount.ts
@@ -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,
@@ -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
@@ -121,6 +126,7 @@ export const useEulerAccount = () => {
const gen = positionGuard.current()
try {
+ hasPositionsFetchError.value = false
if (!walletAddress) {
visiblePortfolio.value = undefined
allPortfolio.value = undefined
@@ -162,6 +168,7 @@ export const useEulerAccount = () => {
catch (error) {
if (positionGuard.isStale(gen)) return
logWarn('useEulerAccount/fetchAndUpdatePortfolio', error)
+ hasPositionsFetchError.value = true
portfolioDiagnostics.value = [{
code: 'SOURCE_UNAVAILABLE',
severity: 'error',
@@ -227,6 +234,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))
@@ -276,6 +289,7 @@ export const useEulerAccount = () => {
return {
portfolio,
portfolioDiagnostics,
+ hasPortfolioLoadError,
borrowPositions,
depositPositions,
removedBorrowPositions,
diff --git a/pages/portfolio.vue b/pages/portfolio.vue
index ad0eee945..1c76e84d3 100644
--- a/pages/portfolio.vue
+++ b/pages/portfolio.vue
@@ -20,6 +20,7 @@ const {
isPositionsLoaded,
isShowAllPositions,
refreshAllPositions,
+ hasPortfolioLoadError,
} = useEulerAccount()
const { refresh: refreshFreshAccount } = useFreshAccount()
const { rewards } = useSdkRewards()
@@ -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)
@@ -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)
@@ -173,6 +181,10 @@ const updatePositions = async (
})
}
+const retryPortfolioLoad = () => {
+ void updatePositions({ portfolioSource: 'fresh', preemptPortfolio: true })
+}
+
onActivated(async () => {
await updateBalances()
updatePositions()
@@ -218,6 +230,26 @@ watch(showAllLabelEntries, (showAll) => {
+
+
+
+ We couldn't load your portfolio. Your funds are safe on-chain — this is usually temporary.
+
+
+
+
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
@@ -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 }}
diff --git a/tests/composables/useEulerAccount.test.ts b/tests/composables/useEulerAccount.test.ts
index 3efdb00d9..08566d0f7 100644
--- a/tests/composables/useEulerAccount.test.ts
+++ b/tests/composables/useEulerAccount.test.ts
@@ -3,22 +3,25 @@ import { effectScope, nextTick, ref, type EffectScope } from 'vue'
const owner = '0x1000000000000000000000000000000000000000'
-const importUseEulerAccount = async () => {
+const importUseEulerAccount = async ({ failFetch = false }: { failFetch?: boolean } = {}) => {
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 fetchPortfolio = vi.fn(async () => {
+ if (failFetch) throw new Error('portfolio source unavailable')
+ return {
+ 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'],
@@ -122,4 +125,23 @@ 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 | 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)
+ })
})
From 95a23fc2f70ab476c08c776d44a0f371284b9c35 Mon Sep 17 00:00:00 2001
From: Seranged <80223622+Seranged@users.noreply.github.com>
Date: Thu, 9 Jul 2026 16:09:02 +0100
Subject: [PATCH 2/2] fix: keep portfolio error during retry
Clear the portfolio fetch error only after a successful refresh confirms replacement data, and cover the pending retry window with a composable regression test.
---
composables/useEulerAccount.ts | 3 +-
tests/composables/useEulerAccount.test.ts | 92 +++++++++++++++++------
2 files changed, 70 insertions(+), 25 deletions(-)
diff --git a/composables/useEulerAccount.ts b/composables/useEulerAccount.ts
index 2f5dd033a..6edb1d48e 100644
--- a/composables/useEulerAccount.ts
+++ b/composables/useEulerAccount.ts
@@ -126,11 +126,11 @@ export const useEulerAccount = () => {
const gen = positionGuard.current()
try {
- hasPositionsFetchError.value = false
if (!walletAddress) {
visiblePortfolio.value = undefined
allPortfolio.value = undefined
portfolioDiagnostics.value = []
+ hasPositionsFetchError.value = false
markLoaded()
return
}
@@ -163,6 +163,7 @@ export const useEulerAccount = () => {
allPortfolio.value = nextAllPortfolio
visiblePortfolio.value = nextVisiblePortfolio
portfolioDiagnostics.value = fetched.errors
+ hasPositionsFetchError.value = false
markLoaded()
}
catch (error) {
diff --git a/tests/composables/useEulerAccount.test.ts b/tests/composables/useEulerAccount.test.ts
index 08566d0f7..7410baecd 100644
--- a/tests/composables/useEulerAccount.test.ts
+++ b/tests/composables/useEulerAccount.test.ts
@@ -3,35 +3,47 @@ import { effectScope, nextTick, ref, type EffectScope } from 'vue'
const owner = '0x1000000000000000000000000000000000000000'
-const importUseEulerAccount = async ({ failFetch = false }: { failFetch?: boolean } = {}) => {
+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>
+ } = {},
+) => {
vi.resetModules()
const fetchPortfolio = vi.fn(async () => {
+ if (fetchPortfolioImpl) return fetchPortfolioImpl()
if (failFetch) throw new Error('portfolio source unavailable')
- return {
- errors: [],
- result: {
- account: { owner },
- borrows: ['all-borrow'],
- savings: ['all-saving'],
- totalSuppliedValueUsd: 100,
- totalBorrowedValueUsd: 25,
- netAssetValueUsd: 75,
- roe: 3,
- netApy: 2,
- },
- }
+ return portfolioResponse()
})
- const buildPortfolio = vi.fn(() => ({
- account: { owner },
- borrows: ['visible-borrow'],
- savings: [],
- totalSuppliedValueUsd: 40,
- totalBorrowedValueUsd: 10,
- netAssetValueUsd: 30,
- roe: 1,
- netApy: 0.5,
- }))
+ const buildPortfolio = vi.fn(() => visiblePortfolio)
const sdk = {
portfolioService: {
fetchPortfolio,
@@ -144,4 +156,36 @@ describe('useEulerAccount', () => {
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) => void) | undefined
+ const fetchPortfolioImpl = vi.fn()
+ .mockRejectedValueOnce(new Error('portfolio source unavailable'))
+ .mockImplementationOnce(() => new Promise>((resolve) => {
+ resolveRetry = resolve
+ }))
+
+ const { useEulerAccount, fetchPortfolio } = await importUseEulerAccount({ fetchPortfolioImpl })
+
+ let account: ReturnType | 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)
+ })
})