diff --git a/app/(protected)/(tabs)/savings.tsx b/app/(protected)/(tabs)/savings.tsx index 9eed8b306..6b07f927f 100644 --- a/app/(protected)/(tabs)/savings.tsx +++ b/app/(protected)/(tabs)/savings.tsx @@ -88,9 +88,16 @@ function LegacySavings() { const { data: exchangeRate } = useVaultExchangeRate(currentVault.name); const rawAllTime = apys?.allTime; + // Shown in the "All time yield" panel and used for the 1Y projection. const vaultAPY = rawAllTime != null && Number.isFinite(Number(rawAllTime)) ? Number(rawAllTime) : 0; + // Rate the live counters tick at. It must be the same figure the screen + // advertises as "Current Yield" (maxAPY) — ticking on the all-time APY while + // displaying the recent one made the two disagree, so a change in the headline + // rate looked unrelated to how fast the numbers moved. + const tickAPY = Number.isFinite(maxAPY) ? maxAPY : 0; + const { data: lastTimestamp } = useLatestTokenTransfer( user?.safeAddress ?? '', // Use correct token address based on selected vault @@ -113,11 +120,10 @@ function LegacySavings() { currentVault.decimals, ); - // Backend summary for soFUSE interest (FUSE has no subgraph) - const { data: savingsSummary } = useSavingsSummary( - currentVault.name, - currentVault.name === 'FUSE', - ); + // Backend summary is the source of truth for interest earned on every vault: + // it measures realized profit against a high-water-mark exchange rate, so the + // figure can't step backwards on a transient NAV dip. + const { data: savingsSummary } = useSavingsSummary(currentVault.name); const isLoading = isBalanceLoading || isTransactionsLoading; const isEmptyStateLoading = isTotalBalanceLoading || isTransactionsLoading; @@ -248,7 +254,7 @@ function LegacySavings() { balance={balance ?? 0} decimalPlaces={currentVault.name === 'ETH' ? 8 : 2} decimals={currentVault.decimals} - apy={vaultAPY} + apy={tickAPY} lastTimestamp={firstDepositTimestamp ?? 0} userDepositTransactions={userDepositTransactions} exchangeRate={exchangeRate} @@ -297,7 +303,7 @@ function LegacySavings() { suffix={displaySuffix ?? ''} balance={balance ?? 0} decimals={currentVault.decimals} - apy={vaultAPY} + apy={tickAPY} lastTimestamp={firstDepositTimestamp ?? 0} mode={SavingMode.CURRENT} inputsReady={ @@ -451,7 +457,7 @@ function LegacySavings() { balance={balance ?? 0} decimalPlaces={currentVault.name === 'ETH' ? 8 : 2} decimals={currentVault.decimals} - apy={vaultAPY} + apy={tickAPY} lastTimestamp={firstDepositTimestamp ?? 0} userDepositTransactions={userDepositTransactions} exchangeRate={exchangeRate} @@ -500,7 +506,7 @@ function LegacySavings() { suffix={displaySuffix ?? ''} balance={balance ?? 0} decimals={currentVault.decimals} - apy={vaultAPY} + apy={tickAPY} lastTimestamp={firstDepositTimestamp ?? 0} mode={SavingMode.CURRENT} inputsReady={ diff --git a/components/Savings/NewSavings/VaultSavingsSection.tsx b/components/Savings/NewSavings/VaultSavingsSection.tsx index c985ecabd..5b31c6e69 100644 --- a/components/Savings/NewSavings/VaultSavingsSection.tsx +++ b/components/Savings/NewSavings/VaultSavingsSection.tsx @@ -5,12 +5,7 @@ import { fuse, mainnet } from 'viem/chains'; import { Text } from '@/components/ui/text'; import { VAULTS } from '@/constants/vaults'; -import { - useAPYs, - useLatestTokenTransfer, - useMaxAPY, - useUserTransactions, -} from '@/hooks/useAnalytics'; +import { useLatestTokenTransfer, useMaxAPY, useUserTransactions } from '@/hooks/useAnalytics'; import { useDepositCalculations } from '@/hooks/useDepositCalculations'; import { useNativePriceUsd } from '@/hooks/useNativePriceUsd'; import { useSavingsSummary } from '@/hooks/useSavingsSummary'; @@ -53,12 +48,12 @@ const VaultSavingsSection = ({ vaultType }: VaultSavingsSectionProps) => { const display = getVaultDisplay(vaultType); const { data: balance } = useVaultBalance(user?.safeAddress as Address, vault); - const { maxAPY } = useMaxAPY(vault.type); - const { data: apys, isLoading: isAPYsLoading } = useAPYs(vault.type); + const { maxAPY, isAPYsLoading } = useMaxAPY(vault.type); const { data: exchangeRate } = useVaultExchangeRate(vault.name); - const vaultAPY = - apys?.allTime != null && Number.isFinite(Number(apys.allTime)) ? Number(apys.allTime) : 0; + // Tick the live interest counter at the rate this card displays (maxAPY), not + // the all-time APY — the two disagree, and the counter must match the headline. + const tickAPY = Number.isFinite(maxAPY) ? maxAPY : 0; const { data: lastTimestamp } = useLatestTokenTransfer( user?.safeAddress ?? '', @@ -75,17 +70,19 @@ const VaultSavingsSection = ({ vaultType }: VaultSavingsSectionProps) => { lastTimestamp, vault.decimals, ); - const { data: savingsSummary } = useSavingsSummary(vault.name, vault.name === 'FUSE'); + // Source of truth for interest earned on every vault (high-water-mark based). + const { data: savingsSummary } = useSavingsSummary(vault.name); // USD price of the vault's native token (1 for USDC; FUSE/ETH priced live). const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', vault.name === 'FUSE'); const ethPriceUsd = useNativePriceUsd(mainnet.id, 'ethPriceUsd', vault.name === 'ETH'); const priceUsd = vault.name === 'USDC' ? 1 : vault.name === 'FUSE' ? fusePriceUsd : ethPriceUsd; - // Live interest (USD for USDC/FUSE; ETH-native × price for ETH). + // Live interest, denominated in the vault's base asset (USD for soUSD, FUSE + // for soFUSE, ETH for soETH) — converted to USD below. const interestRaw = useSavingsYield({ balance: balance ?? 0, - apy: vaultAPY, + apy: tickAPY, lastTimestamp: firstDepositTimestamp ?? 0, mode: SavingMode.CURRENT, decimals: vault.decimals, @@ -99,7 +96,10 @@ const VaultSavingsSection = ({ vaultType }: VaultSavingsSectionProps) => { const redeemableNative = (balance ?? 0) * (exchangeRate ?? 1); const availableUsd = vault.name === 'USDC' ? redeemableNative : redeemableNative * priceUsd; - const interestUsd = vault.name === 'ETH' ? interestRaw * priceUsd : interestRaw; + // Interest must be converted on the same basis as `availableUsd`. FUSE was + // previously left unconverted, so on the soFUSE card a FUSE-denominated + // interest figure was rendered with a $ sign and subtracted from a USD total. + const interestUsd = vault.name === 'USDC' ? interestRaw : interestRaw * priceUsd; const depositedUsd = Math.max(availableUsd - interestUsd, 0); // No exact historical "this month" breakdown exists, so approximate from APY: @@ -133,7 +133,7 @@ const VaultSavingsSection = ({ vaultType }: VaultSavingsSectionProps) => { - + diff --git a/hooks/useFinancial.ts b/hooks/useFinancial.ts index ba40513b0..871d88629 100644 --- a/hooks/useFinancial.ts +++ b/hooks/useFinancial.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { calculateYield } from '@/lib/financial'; +import { calculateYield, INTEREST_UNAVAILABLE } from '@/lib/financial'; import { SavingMode } from '@/lib/types'; export const useCalculateSavings = ( @@ -52,6 +52,9 @@ export const useCalculateSavings = ( transactionsRef.current, calculationParams.safeAddress, ); + // Interest couldn't be measured from deposit history — keep the last value + // rather than publishing the sentinel. + if (calculatedSavings === INTEREST_UNAVAILABLE) return; setSavings(calculatedSavings); }, [calculationParams]); diff --git a/hooks/useSavingsYield.ts b/hooks/useSavingsYield.ts index b1ee94993..479231bd7 100644 --- a/hooks/useSavingsYield.ts +++ b/hooks/useSavingsYield.ts @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import { GetUserTransactionsQuery } from '@/graphql/generated/user-info'; import useUser from '@/hooks/useUser'; import { ADDRESSES } from '@/lib/config'; -import { calculateYield, SECONDS_PER_YEAR } from '@/lib/financial'; +import { calculateYield, INTEREST_UNAVAILABLE, SECONDS_PER_YEAR } from '@/lib/financial'; import { SavingMode, SavingsSummaryResponse } from '@/lib/types'; function amountGained( @@ -17,17 +17,6 @@ function amountGained( return (((apy / 100) * (toTs - fromTs)) / SECONDS_PER_YEAR) * balanceUSD; } -function totalUsdLive( - balance: number, - exchangeRate: number, - apy: number, - lastTs: number, - now: number, -): number { - const balanceUSD = balance * exchangeRate; - return balanceUSD + amountGained(balance, exchangeRate, apy, lastTs, now); -} - export interface UseSavingsYieldParams { balance: number; apy: number; @@ -39,12 +28,28 @@ export interface UseSavingsYieldParams { tokenAddress?: string; /** When true, treat interest inputs as loaded. Omit to use internal buckets. */ inputsReady?: boolean; - /** Backend savings summary — used for soFUSE interest (no subgraph available) */ + /** Backend savings summary — the preferred source for interest earned. */ summary?: SavingsSummaryResponse | null; - /** Vault identifier ('USDC' | 'FUSE') — FUSE uses backend summary, USDC uses subgraph */ + /** Vault identifier ('USDC' | 'FUSE' | 'ETH'). */ vault?: string; } +/** + * Live savings figure for a vault. + * + * For the interest modes, the value is a *measurement* of realized profit + * (`total value - total deposited`) taken at a known instant, plus an APY + * projection over only the seconds elapsed since that instant. That keeps the + * counter smooth without letting a rate change retroactively re-price the whole + * holding period — the behaviour that made "interest earned" fall when the APY + * dipped. + * + * Preferred source is the backend `/savings/summary`, which measures against a + * high-water-mark exchange rate so the figure never steps backwards on a + * transient NAV dip. The subgraph calculation is the fallback; if neither can + * establish realized profit, the last known value is held rather than replaced + * by a projection. + */ export function useSavingsYield({ balance, apy, @@ -78,20 +83,24 @@ export function useSavingsYield({ // Full calc only when inputs change (no animation). For TOTAL_USD use redeemable only so display matches withdraw. useEffect(() => { - // soFUSE CURRENT mode: interest comes from the backend summary (no subgraph - // for FUSE) and stays valid even if the on-chain balance read is momentarily - // 0 (slow/failed RPC poll or a vault switch). Handle it BEFORE the balance<=0 - // guard so a transient 0 balance can't wipe the anchor and snap interest to 0. - if (mode === SavingMode.CURRENT && vault === 'FUSE') { - if (summary) { - const backendInterest = parseFloat(summary.interestEarnedUSD); - const calculatedAtUnix = Math.floor(new Date(summary.calculatedAt).getTime() / 1000); - if (backendInterest >= 0 && calculatedAtUnix > 0) { - setLiveYield(backendInterest); - setAnchor({ value: backendInterest, time: calculatedAtUnix }); - } + // CURRENT mode: prefer the backend summary for every vault. It is measured + // against a high-water-mark rate and stays valid even if the on-chain balance + // read is momentarily 0 (slow/failed RPC poll or a vault switch), so it is + // handled BEFORE the balance<=0 guard — a transient 0 balance must not wipe + // the anchor and snap interest to 0. + if (mode === SavingMode.CURRENT && summary) { + const backendInterest = parseFloat(summary.interestEarnedUSD); + const calculatedAtUnix = Math.floor(new Date(summary.calculatedAt).getTime() / 1000); + if (isFinite(backendInterest) && backendInterest >= 0 && calculatedAtUnix > 0) { + setLiveYield(backendInterest); + setAnchor({ value: backendInterest, time: calculatedAtUnix }); } - // No summary yet — keep current value until backend responds + return; + } + + // FUSE has no subgraph to fall back on — hold the current value until the + // backend summary responds rather than showing a projection. + if (mode === SavingMode.CURRENT && vault === 'FUSE') { return; } @@ -126,6 +135,9 @@ export function useSavingsYield({ vaultDecimals, ).then(calculatedYield => { if (cancelled) return; + // Realized profit couldn't be established (no deposit history yet) — keep + // whatever is on screen instead of substituting a guess. + if (calculatedYield === INTEREST_UNAVAILABLE) return; const isSpuriousZero = mode === SavingMode.CURRENT && calculatedYield === 0 && balance > 0 && lastTimestamp > 0; if (!isSpuriousZero) { @@ -151,7 +163,9 @@ export function useSavingsYield({ ...(inputsReady !== undefined ? [inputsReady] : [lastTsBucket, apyBucket]), ]); - // Every second: update display with simple formula (no network) + // Every second: update display with simple formula (no network). + // For CURRENT this projects forward from the anchor only — the elapsed period + // is already accounted for by the measured value at anchor.time. useEffect(() => { if (balance <= 0) return; const now = Math.floor(Date.now() / 1000); diff --git a/hooks/useSavingsYieldOld.ts b/hooks/useSavingsYieldOld.ts index 29a34108b..5cb86b3a6 100644 --- a/hooks/useSavingsYieldOld.ts +++ b/hooks/useSavingsYieldOld.ts @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react'; import { GetUserTransactionsQuery } from '@/graphql/generated/user-info'; import useUser from '@/hooks/useUser'; import { ADDRESSES } from '@/lib/config'; -import { calculateYield, SECONDS_PER_YEAR } from '@/lib/financial'; +import { calculateYield, INTEREST_UNAVAILABLE, SECONDS_PER_YEAR } from '@/lib/financial'; import { SavingMode } from '@/lib/types'; function amountGained( @@ -90,6 +90,8 @@ export function useSavingsYieldOld({ vaultDecimals, ).then(calculatedYield => { if (cancelled) return; + // Interest couldn't be measured from deposit history — hold the last value. + if (calculatedYield === INTEREST_UNAVAILABLE) return; const isSpuriousZero = mode === SavingMode.CURRENT && calculatedYield === 0 && balance > 0 && lastTimestamp > 0; if (!isSpuriousZero) { diff --git a/lib/__tests__/savings-interest.test.ts b/lib/__tests__/savings-interest.test.ts new file mode 100644 index 000000000..f0fd196c2 --- /dev/null +++ b/lib/__tests__/savings-interest.test.ts @@ -0,0 +1,144 @@ +/// + +/** + * Regression tests for the "Interest earned" counter. + * + * Reported behaviour: interest showed +1.50 at a 14% headline rate; the rate + * moved to 13.59% and interest dropped to +0.75 — a ~2x fall from a ~3% rate + * change. Two defects produced that: + * + * 1. CURRENT mode returned `realized profit + APY x (now - firstDeposit)`, + * double-counting the holding period and making a measurement scale with a + * forecast rate, so a rate change re-priced all of history. + * 2. When deposit history was momentarily unavailable the same mode silently + * returned the projection *alone* — roughly half of realized+projection — + * so the number swung by ~2x with nothing changing on-chain. + * + * Interest earned is now realized profit only (total value - total deposited), + * and "unknown" is reported as INTEREST_UNAVAILABLE instead of a guess. + */ + +import { + calculateYield, + clearExchangeRateCache, + clearVaultTransfersCache, + INTEREST_UNAVAILABLE, +} from '@/lib/financial'; +import { SavingMode } from '@/lib/types'; + +// jest.mock calls are hoisted above the imports above. +jest.mock('@/graphql/clients', () => ({ + getInfoClient: () => ({ + query: jest.fn().mockResolvedValue({ data: { exchangeRateUpdates: [] } }), + }), +})); + +jest.mock('@/store/useBalanceStore', () => ({ + useBalanceStore: { getState: () => ({ setEarnedUSD: jest.fn() }) }, +})); + +const SAFE = '0x1111111111111111111111111111111111111111'; +const USDC_VAULT = '0xcE0B0E7B6a8a2571AA9B47bFB4Ac6D0F2fF5CE60'; + +const YEAR_SECONDS = 31_557_600; +const NOW = 1_800_000_000; +const ONE_YEAR_AGO = NOW - YEAR_SECONDS; + +/** 1000 USDC deposited a year ago (6 decimals). */ +const deposits = [{ depositAmount: '1000000000', depositTimestamp: String(ONE_YEAR_AGO) }]; +const withdraws: unknown[] = []; + +const interestFor = (balance: number, apy: number, mode = SavingMode.CURRENT) => + calculateYield( + balance, + apy, + ONE_YEAR_AGO, + NOW, + mode, + { deposits, withdraws }, + SAFE, + 1, // exchange rate: 1 soUSD = 1 USD + USDC_VAULT, + 6, + ); + +beforeEach(() => { + clearExchangeRateCache(); + clearVaultTransfersCache(); + // No on-chain vault transfers: deposits/withdraws above are the whole history. + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ items: [] }), + }) as unknown as typeof fetch; +}); + +describe('calculateYield — interest earned is a measurement, not a forecast', () => { + it('reports realized profit only, with no APY projection layered on top', async () => { + // 1010 soUSD at rate 1.0 against 1000 deposited = 10.00 realized. + // The old formula added 1010 x 14% x 1yr = ~141 on top of that. + await expect(interestFor(1010, 14)).resolves.toBeCloseTo(10, 6); + }); + + it('does not move when the APY changes', async () => { + const [at14, at1359, at0] = await Promise.all([ + interestFor(1010, 14), + interestFor(1010, 13.59), + interestFor(1010, 0), + ]); + + expect(at1359).toBeCloseTo(at14, 6); + expect(at0).toBeCloseTo(at14, 6); + }); + + it('still reports interest when the APY fetch failed (NaN)', async () => { + // APY is not an input to a measurement, so losing it must not blank the value. + await expect(interestFor(1010, NaN)).resolves.toBeCloseTo(10, 6); + }); + + it('clamps a position that is underwater to zero rather than going negative', async () => { + await expect(interestFor(990, 14)).resolves.toBe(0); + }); + + it('treats INTEREST_ONLY the same as CURRENT', async () => { + await expect(interestFor(1010, 14, SavingMode.INTEREST_ONLY)).resolves.toBeCloseTo(10, 6); + }); + + it('reports INTEREST_UNAVAILABLE instead of a projection when deposit history is missing', async () => { + // This is the ~2x swing: the old code returned 1010 x 14% x 1yr (~141) here. + const noHistory = await calculateYield( + 1010, + 14, + ONE_YEAR_AGO, + NOW, + SavingMode.CURRENT, + undefined, + SAFE, + 1, + USDC_VAULT, + 6, + ); + + expect(noHistory).toBe(INTEREST_UNAVAILABLE); + }); + + it('reports INTEREST_UNAVAILABLE when no deposit start time is known', async () => { + const noStart = await calculateYield( + 1010, + 14, + 0, + NOW, + SavingMode.CURRENT, + undefined, + SAFE, + 1, + USDC_VAULT, + 6, + ); + + expect(noStart).toBe(INTEREST_UNAVAILABLE); + }); + + it('returns 0 interest for an empty position', async () => { + await expect(interestFor(0, 14)).resolves.toBe(0); + }); +}); diff --git a/lib/financial.ts b/lib/financial.ts index dd935ea46..df5e991f1 100644 --- a/lib/financial.ts +++ b/lib/financial.ts @@ -13,6 +13,19 @@ import { SavingMode } from './types'; export const SECONDS_PER_YEAR = 31_557_600; +/** + * Sentinel returned by `calculateYield` for the interest modes (CURRENT / + * INTEREST_ONLY) when interest cannot be derived from real data — no deposit + * history yet, or the subgraph query came back empty. + * + * Interest earned is a measurement, not a forecast, so there is no safe numeric + * answer in that case: returning an APY projection instead used to make the + * counter jump by ~2x whenever the deposit history flickered in and out, and + * returning 0 wipes a real balance's interest to nothing. Callers must keep + * showing the last known value (or a skeleton) when they see this. + */ +export const INTEREST_UNAVAILABLE = -1; + // Cache for API responses to prevent repeated calls const exchangeRateCache = new Map; timestamp: number }>(); const vaultTransfersCache = new Map(); @@ -230,8 +243,7 @@ export const calculateActualDepositedAmount = async ( tokenAddress.toLowerCase() === ADDRESSES.fuse.vault.toLowerCase() || tokenAddress.toLowerCase() === ADDRESSES.ethereum.vault.toLowerCase(); // Subgraph deposits/withdrawals are USDC-vault only; for FUSE vault use only chain vault transfers - const isFuseVault = - tokenAddress.toLowerCase() === ADDRESSES.fuse.fuseVault.toLowerCase(); + const isFuseVault = tokenAddress.toLowerCase() === ADDRESSES.fuse.fuseVault.toLowerCase(); const vaultTransferAddresses = isUsdcVault ? [ADDRESSES.ethereum.vault, ADDRESSES.fuse.vault] @@ -359,6 +371,21 @@ export const calculateActualDepositedAmount = async ( return { actualDeposited, timeWeightedBalances }; }; +/** + * Compute a savings figure for the given display mode. + * + * The interest modes (CURRENT / INTEREST_ONLY) are *measurements*: they report + * realized profit only — `balance x exchangeRate - actualDeposited` — and never + * add an APY projection over the holding period. Layering a projection on top of + * realized profit double-counted the same period and made "interest earned" + * scale with the APY, so the displayed total moved retroactively (downwards) + * every time the vault's rate changed. Forward-looking smoothing belongs to the + * per-second tick in `useSavingsYield`, which only projects from the timestamp + * the measurement was taken. + * + * When realized profit can't be established, the interest modes return + * INTEREST_UNAVAILABLE rather than guessing. + */ export const calculateYield = async ( balance: number, apy: number, @@ -371,14 +398,18 @@ export const calculateYield = async ( tokenAddress: string = ADDRESSES.fuse.vault, decimals: number = 6, ): Promise => { + const isInterestMode = mode === SavingMode.CURRENT || mode === SavingMode.INTEREST_ONLY; + if (balance <= 0 || !isFinite(balance)) return 0; if (mode === SavingMode.BALANCE_ONLY) return balance; - if (!isFinite(apy) || apy < 0) return mode === SavingMode.INTEREST_ONLY ? 0 : balance; - // Without a valid start time we can't compute interest; return 0 for interest modes - if (!lastTimestamp || lastTimestamp <= 0) - return mode === SavingMode.INTEREST_ONLY || mode === SavingMode.CURRENT ? 0 : balance; - if (!currentTime || currentTime <= 0) - return mode === SavingMode.INTEREST_ONLY || mode === SavingMode.CURRENT ? 0 : balance; + // APY and a start time are only inputs to the projecting modes. The interest + // modes are derived from deposit history, so a missing/failed APY fetch must + // not blank out interest that is already sitting on-chain. + if (!isInterestMode) { + if (!isFinite(apy) || apy < 0) return balance; + if (!lastTimestamp || lastTimestamp <= 0) return balance; + if (!currentTime || currentTime <= 0) return balance; + } const { setEarnedUSD } = useBalanceStore.getState(); @@ -416,17 +447,17 @@ export const calculateYield = async ( interestEarnedUSD = 0; } - const amountGained = - (balanceUSD * (apy / 100) * (currentTime - lastTimestamp)) / SECONDS_PER_YEAR; - const currentInterest = Math.max(0, interestEarnedUSD + amountGained); - if (mode === SavingMode.CURRENT) { - return currentInterest; - } - - if (mode === SavingMode.INTEREST_ONLY) { + // Realized profit is the whole answer for the interest modes — no APY + // projection is layered on top (see the function doc). + if (isInterestMode) { return Math.max(0, interestEarnedUSD); } + const elapsed = + lastTimestamp > 0 && currentTime > lastTimestamp ? currentTime - lastTimestamp : 0; + const amountGained = + isFinite(apy) && apy > 0 ? (balanceUSD * (apy / 100) * elapsed) / SECONDS_PER_YEAR : 0; + if (mode === SavingMode.TOTAL_USD) { return balanceUSD + amountGained; } @@ -454,21 +485,24 @@ export const calculateYield = async ( } } + // No usable deposit history. The projecting modes can still fall back to a + // simple APY estimate, but the interest modes cannot: reporting a projection as + // "interest earned" is what made the counter swing by ~2x whenever this + // fallback was hit. Signal "unknown" and let the caller hold the last value. + if (isInterestMode) { + return INTEREST_UNAVAILABLE; + } + // Fallback to original calculation const deltaTime = Math.max(0, currentTime - lastTimestamp); const timeInYears = deltaTime / SECONDS_PER_YEAR; const interestEarned = balance * (apy / 100) * timeInYears; const interestEarnedUSD = balanceUSD * (apy / 100) * timeInYears; if (deltaTime === 0) { - if (mode === SavingMode.INTEREST_ONLY || mode === SavingMode.CURRENT) return 0; if (mode === SavingMode.TOTAL_USD) return balanceUSD; return balance; } - if (mode === SavingMode.INTEREST_ONLY) { - return Math.max(0, interestEarnedUSD); - } - if (mode === SavingMode.TOTAL) { return balance + interestEarned; } @@ -482,10 +516,6 @@ export const calculateYield = async ( return totalReturnPercentage; } - if (mode === SavingMode.CURRENT) { - return Math.max(0, interestEarnedUSD); - } - if (mode === SavingMode.TOTAL_USD) { return balanceUSD + interestEarnedUSD; }