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;
}