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
24 changes: 15 additions & 9 deletions app/(protected)/(tabs)/savings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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={
Expand Down
30 changes: 15 additions & 15 deletions components/Savings/NewSavings/VaultSavingsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ?? '',
Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -133,7 +133,7 @@ const VaultSavingsSection = ({ vaultType }: VaultSavingsSectionProps) => {
<Stat label="Interest earned" value={fmtUsd(interestUsd)} />
</View>
<View className="flex-row">
<Stat label="This month" value={`+${fmtUsd(thisMonthUsd)}`} positive />
<Stat label="This month (est.)" value={`+${fmtUsd(thisMonthUsd)}`} positive />
<Stat label="Next 30 days (est.)" value={`+${fmtUsd(nextThirtyUsd)}`} positive />
</View>
</View>
Expand Down
5 changes: 4 additions & 1 deletion hooks/useFinancial.ts
Original file line number Diff line number Diff line change
@@ -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 = (
Expand Down Expand Up @@ -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]);

Expand Down
70 changes: 42 additions & 28 deletions hooks/useSavingsYield.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion hooks/useSavingsYieldOld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading