diff --git a/composables/useEulerAccount.ts b/composables/useEulerAccount.ts index ca024f8d1..6edb1d48e 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 @@ -125,6 +130,7 @@ export const useEulerAccount = () => { visiblePortfolio.value = undefined allPortfolio.value = undefined portfolioDiagnostics.value = [] + hasPositionsFetchError.value = false markLoaded() return } @@ -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', @@ -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)) @@ -276,6 +290,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..7410baecd 100644 --- a/tests/composables/useEulerAccount.test.ts +++ b/tests/composables/useEulerAccount.test.ts @@ -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> + } = {}, +) => { 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, @@ -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 | 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) => 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) + }) })