@@ -151,6 +163,8 @@ export default function Positions() {
refetch={() => void refetch()}
isRefetching={isRefetching}
isLoadingEarnings={isEarningsLoading}
+ earningsPeriod={earningsPeriod}
+ setEarningsPeriod={setEarningsPeriod}
/>
)}
diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx
index 7dc01503..972168c7 100644
--- a/app/positions/components/PositionsSummaryTable.tsx
+++ b/app/positions/components/PositionsSummaryTable.tsx
@@ -24,10 +24,10 @@ import { TooltipContent } from '@/components/TooltipContent';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { computeMarketWarnings } from '@/hooks/useMarketWarnings';
import { useStyledToast } from '@/hooks/useStyledToast';
+import { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData';
import { formatReadable, formatBalance } from '@/utils/balance';
import { getNetworkImg } from '@/utils/networks';
import {
- EarningsPeriod,
getGroupedEarnings,
groupPositionsByLoanAsset,
processCollaterals,
@@ -127,6 +127,8 @@ type PositionsSummaryTableProps = {
refetch: (onSuccess?: () => void) => void;
isRefetching: boolean;
isLoadingEarnings?: boolean;
+ earningsPeriod: EarningsPeriod;
+ setEarningsPeriod: (period: EarningsPeriod) => void;
};
export function PositionsSummaryTable({
@@ -138,14 +140,14 @@ export function PositionsSummaryTable({
isRefetching,
isLoadingEarnings,
account,
+ earningsPeriod,
+ setEarningsPeriod,
}: PositionsSummaryTableProps) {
const [expandedRows, setExpandedRows] = useState
>(new Set());
const [showRebalanceModal, setShowRebalanceModal] = useState(false);
const [selectedGroupedPosition, setSelectedGroupedPosition] = useState(
null,
);
-
- const [earningsPeriod, setEarningsPeriod] = useState(EarningsPeriod.Day);
const [showEmptyPositions, setShowEmptyPositions] = useLocalStorage(
PositionsShowEmptyKey,
false,
@@ -164,10 +166,10 @@ export function PositionsSummaryTable({
}, [account, address]);
const periodLabels: Record = {
- [EarningsPeriod.All]: 'All Time',
- [EarningsPeriod.Day]: '1D',
- [EarningsPeriod.Week]: '7D',
- [EarningsPeriod.Month]: '30D',
+ 'all': 'All Time',
+ 'day': '1D',
+ 'week': '7D',
+ 'month': '30D',
};
const groupedPositions = useMemo(
@@ -329,7 +331,7 @@ export function PositionsSummaryTable({
const isExpanded = expandedRows.has(rowKey);
const avgApy = groupedPosition.totalWeightedApy;
- const earnings = getGroupedEarnings(groupedPosition, earningsPeriod);
+ const earnings = getGroupedEarnings(groupedPosition);
return (
@@ -376,7 +378,7 @@ export function PositionsSummaryTable({
) : (
{(() => {
- if (earnings === null) return '-';
+ if (earnings === 0n) return '-';
return (
formatReadable(
Number(
diff --git a/src/components/Status/LoadingScreen.tsx b/src/components/Status/LoadingScreen.tsx
index 420d80c9..0a0d2337 100644
--- a/src/components/Status/LoadingScreen.tsx
+++ b/src/components/Status/LoadingScreen.tsx
@@ -1,3 +1,6 @@
+'use client';
+
+import { useState, useEffect } from 'react';
import Image from 'next/image';
import { BarLoader } from 'react-spinners';
import loadingImg from '../imgs/aragon/loading.png';
@@ -7,7 +10,83 @@ type LoadingScreenProps = {
className?: string;
};
-export default function LoadingScreen({ message = 'Loading...', className }: LoadingScreenProps) {
+const loadingPhrases = [
+ 'Loading...',
+ 'Fetching data...',
+ 'Almost there...',
+ 'Preparing your view...',
+ 'Connecting to Morpho...',
+];
+
+function TypingAnimation({ phrases }: { phrases: string[] }) {
+ const [displayText, setDisplayText] = useState('');
+ const [phraseIndex, setPhraseIndex] = useState(0);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isPaused, setIsPaused] = useState(false);
+ const [showCursor, setShowCursor] = useState(true);
+
+ useEffect(() => {
+ const cursorInterval = setInterval(() => {
+ setShowCursor((prev) => !prev);
+ }, 530);
+
+ return () => clearInterval(cursorInterval);
+ }, []);
+
+ useEffect(() => {
+ if (isPaused) {
+ const pauseTimeout = setTimeout(() => {
+ setIsPaused(false);
+ setIsDeleting(true);
+ }, 1500);
+ return () => clearTimeout(pauseTimeout);
+ }
+
+ const currentPhrase = phrases[phraseIndex];
+ const targetText = currentPhrase;
+
+ const getNextPhraseIndex = (current: number) => (current + 1) % phrases.length;
+
+ const typingSpeed = 40;
+ const deletingSpeed = 25;
+
+ const timeout = setTimeout(() => {
+ if (!isDeleting) {
+ if (displayText.length < targetText.length) {
+ setDisplayText(targetText.slice(0, displayText.length + 1));
+ } else {
+ setIsPaused(true);
+ }
+ } else {
+ if (displayText.length > 0) {
+ setDisplayText(displayText.slice(0, -1));
+ } else {
+ setIsDeleting(false);
+ setPhraseIndex(getNextPhraseIndex(phraseIndex));
+ }
+ }
+ }, isDeleting ? deletingSpeed : typingSpeed);
+
+ return () => clearTimeout(timeout);
+ }, [displayText, phraseIndex, isDeleting, isPaused, phrases]);
+
+ return (
+
+ {displayText}
+
+ |
+
+
+ );
+}
+
+export default function LoadingScreen({ message, className }: LoadingScreenProps) {
+ const phrases = message ? [message] : loadingPhrases;
+ const showTyping = !message;
+
return (
-
{message}
+
+ {showTyping ? : message}
+
);
}
diff --git a/src/components/layout/header/Header.tsx b/src/components/layout/header/Header.tsx
index 5ea51094..5503b3cd 100644
--- a/src/components/layout/header/Header.tsx
+++ b/src/components/layout/header/Header.tsx
@@ -32,10 +32,10 @@ function Header({ ghost }: HeaderProps) {
return (
<>
- {/* Spacer div */}
+ {/* Spacer div */}
diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts
index 427091cc..fab2d1ab 100644
--- a/src/hooks/usePositionReport.ts
+++ b/src/hooks/usePositionReport.ts
@@ -5,7 +5,7 @@ import {
filterTransactionsInPeriod,
} from '@/utils/interest';
import { SupportedNetworks } from '@/utils/networks';
-import { fetchPositionSnapshot } from '@/utils/positions';
+import { fetchPositionsSnapshots } from '@/utils/positions';
import { estimatedBlockNumber, getClient } from '@/utils/rpc';
import { Market, MarketPosition, UserTransaction } from '@/utils/types';
import { useCustomRpc } from './useCustomRpc';
@@ -104,63 +104,72 @@ export const usePositionReport = (
}
}
- const marketReports = (
- await Promise.all(
- relevantPositions.map(async (position) => {
- const publicClient = getClient(
- position.market.morphoBlue.chain.id,
- customRpcUrls[position.market.morphoBlue.chain.id as SupportedNetworks] ?? undefined,
- );
- const startSnapshot = await fetchPositionSnapshot(
- position.market.uniqueKey,
- account,
- position.market.morphoBlue.chain.id,
- startBlockNumber,
- publicClient,
- );
- const endSnapshot = await fetchPositionSnapshot(
- position.market.uniqueKey,
- account,
- position.market.morphoBlue.chain.id,
- endBlockNumber,
- publicClient,
- );
-
- if (!startSnapshot || !endSnapshot) {
- return;
- }
-
- const marketTransactions = filterTransactionsInPeriod(
- allTransactions.filter(
- (tx) => tx.data?.market?.uniqueKey === position.market.uniqueKey,
- ),
- startTimestamp,
- endTimestamp,
- );
-
- const earnings = calculateEarningsFromSnapshot(
- BigInt(endSnapshot.supplyAssets),
- BigInt(startSnapshot.supplyAssets),
- marketTransactions,
- startTimestamp,
- endTimestamp,
- );
-
- return {
- market: position.market,
- interestEarned: earnings.earned,
- totalDeposits: earnings.totalDeposits,
- totalWithdraws: earnings.totalWithdraws,
- apy: earnings.apy,
- avgCapital: earnings.avgCapital,
- effectiveTime: earnings.effectiveTime,
- startBalance: BigInt(startSnapshot.supplyAssets),
- endBalance: BigInt(endSnapshot.supplyAssets),
- transactions: marketTransactions,
- };
- }),
- )
- ).filter((report) => report !== null && report !== undefined) as PositionReport[];
+ // Batch fetch all snapshots using multicall
+ const marketIds = relevantPositions.map((position) => position.market.uniqueKey);
+ const publicClient = getClient(
+ selectedAsset.chainId as SupportedNetworks,
+ customRpcUrls[selectedAsset.chainId as SupportedNetworks] ?? undefined,
+ );
+
+ // Fetch start and end snapshots in parallel (batched per block number)
+ const [startSnapshots, endSnapshots] = await Promise.all([
+ fetchPositionsSnapshots(
+ marketIds,
+ account,
+ selectedAsset.chainId,
+ startBlockNumber,
+ publicClient,
+ ),
+ fetchPositionsSnapshots(
+ marketIds,
+ account,
+ selectedAsset.chainId,
+ endBlockNumber,
+ publicClient,
+ ),
+ ]);
+
+ // Process positions with their snapshots
+ const marketReports = relevantPositions
+ .map((position) => {
+ const marketKey = position.market.uniqueKey;
+ const startSnapshot = startSnapshots.get(marketKey);
+ const endSnapshot = endSnapshots.get(marketKey);
+
+ if (!startSnapshot || !endSnapshot) {
+ return null;
+ }
+
+ const marketTransactions = filterTransactionsInPeriod(
+ allTransactions.filter(
+ (tx) => tx.data?.market?.uniqueKey === marketKey,
+ ),
+ startTimestamp,
+ endTimestamp,
+ );
+
+ const earnings = calculateEarningsFromSnapshot(
+ BigInt(endSnapshot.supplyAssets),
+ BigInt(startSnapshot.supplyAssets),
+ marketTransactions,
+ startTimestamp,
+ endTimestamp,
+ );
+
+ return {
+ market: position.market,
+ interestEarned: earnings.earned,
+ totalDeposits: earnings.totalDeposits,
+ totalWithdraws: earnings.totalWithdraws,
+ apy: earnings.apy,
+ avgCapital: earnings.avgCapital,
+ effectiveTime: earnings.effectiveTime,
+ startBalance: BigInt(startSnapshot.supplyAssets),
+ endBalance: BigInt(endSnapshot.supplyAssets),
+ transactions: marketTransactions,
+ };
+ })
+ .filter((report): report is PositionReport => report !== null);
const totalInterestEarned = marketReports.reduce(
(sum, report) => sum + BigInt(report.interestEarned),
diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts
index 88a0222e..a2eeb7d9 100644
--- a/src/hooks/useUserPositions.ts
+++ b/src/hooks/useUserPositions.ts
@@ -2,12 +2,10 @@ import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Address } from 'viem';
import { supportsMorphoApi } from '@/config/dataSources';
-import { fetchMorphoMarket } from '@/data-sources/morpho-api/market';
import { fetchMorphoUserPositionMarkets } from '@/data-sources/morpho-api/positions';
-import { fetchSubgraphMarket } from '@/data-sources/subgraph/market';
import { fetchSubgraphUserPositionMarkets } from '@/data-sources/subgraph/positions';
import { SupportedNetworks } from '@/utils/networks';
-import { fetchPositionSnapshot, type PositionSnapshot } from '@/utils/positions';
+import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions';
import { getClient } from '@/utils/rpc';
import { Market } from '@/utils/types';
import { useUserMarketsCache } from '../hooks/useUserMarketsCache';
@@ -111,35 +109,6 @@ const fetchSourceMarketKeys = async (
return sourcePositionMarkets;
};
-// Helper function to fetch market data from the appropriate source
-const fetchMarketData = async (marketKey: string, chainId: number): Promise => {
- let market: Market | null = null;
-
- // Try Morpho API first if supported
- if (supportsMorphoApi(chainId)) {
- try {
- console.log(`Attempting to fetch market data via Morpho API for ${marketKey}`);
- market = await fetchMorphoMarket(marketKey, chainId);
- } catch (morphoError) {
- console.error(`Failed to fetch market data via Morpho API:`, morphoError);
- // Continue to Subgraph fallback
- }
- }
-
- // If Morpho API failed or not supported, try Subgraph
- if (!market) {
- try {
- console.log(`Attempting to fetch market data via Subgraph for ${marketKey}`);
- market = await fetchSubgraphMarket(marketKey, chainId);
- } catch (subgraphError) {
- console.error(`Failed to fetch market data via Subgraph:`, subgraphError);
- market = null;
- }
- }
-
- return market;
-};
-
// --- Main Hook --- //
const useUserPositions = (
@@ -205,61 +174,70 @@ const useUserPositions = (
const { finalMarketKeys } = initialData;
- // Fetch market data and snapshots in parallel
- const marketDataPromises = finalMarketKeys.map(async (marketInfo) => {
- const market = await fetchMarketData(marketInfo.marketUniqueKey, marketInfo.chainId);
- if (!market) {
- console.warn(
- `[Positions] Market data not found for ${marketInfo.marketUniqueKey} on chain ${marketInfo.chainId}. Skipping snapshot fetch.`,
+ // Group markets by chainId for batched fetching
+ const marketsByChain = new Map();
+ finalMarketKeys.forEach((marketInfo) => {
+ const existing = marketsByChain.get(marketInfo.chainId) ?? [];
+ existing.push(marketInfo);
+ marketsByChain.set(marketInfo.chainId, existing);
+ });
+
+ // Build market data map from allMarkets context (no need to fetch individually)
+ const marketDataMap = new Map();
+ allMarkets.forEach((market) => {
+ marketDataMap.set(market.uniqueKey.toLowerCase(), market);
+ });
+
+ // Fetch snapshots for each chain using batched multicall
+ const allSnapshots = new Map();
+ await Promise.all(
+ Array.from(marketsByChain.entries()).map(async ([chainId, markets]) => {
+ const publicClient = getClient(
+ chainId as SupportedNetworks,
+ customRpcUrls[chainId as SupportedNetworks] ?? undefined,
+ );
+ if (!publicClient) {
+ console.error(`[Positions] No public client available for chain ${chainId}`);
+ return;
+ }
+
+ const marketIds = markets.map((m) => m.marketUniqueKey);
+ const snapshots = await fetchPositionsSnapshots(
+ marketIds,
+ user as Address,
+ chainId,
+ 0,
+ publicClient,
);
- return null;
- }
- const publicClient = getClient(
- marketInfo.chainId as SupportedNetworks,
- customRpcUrls[marketInfo.chainId as SupportedNetworks] ?? undefined,
- );
- if (!publicClient) {
- console.error(`[Positions] No public client available for chain ${marketInfo.chainId}`);
- return null;
+ // Merge into allSnapshots
+ snapshots.forEach((snapshot, marketId) => {
+ allSnapshots.set(marketId.toLowerCase(), snapshot);
+ });
+ }),
+ );
+
+ // Combine market data with snapshots
+ const validPositions: EnhancedMarketPosition[] = [];
+ finalMarketKeys.forEach((marketInfo) => {
+ const marketKey = marketInfo.marketUniqueKey.toLowerCase();
+ const market = marketDataMap.get(marketKey);
+ const snapshot = allSnapshots.get(marketKey);
+
+ if (!market || !snapshot) return;
+
+ const hasSupply = snapshot.supplyShares.toString() !== '0';
+ const hasBorrow = snapshot.borrowShares.toString() !== '0';
+ const hasCollateral = snapshot.collateral.toString() !== '0';
+
+ if (showEmpty || hasSupply || hasBorrow || hasCollateral) {
+ validPositions.push({
+ state: snapshot,
+ market: market,
+ });
}
-
- const snapshot = await queryClient.fetchQuery({
- queryKey: positionKeys.snapshot(marketInfo.marketUniqueKey, user, marketInfo.chainId),
- queryFn: async () =>
- fetchPositionSnapshot(
- marketInfo.marketUniqueKey,
- user as Address,
- marketInfo.chainId,
- 0,
- publicClient,
- ),
- staleTime: 15000, // 15 seconds - keep position data fresh
- gcTime: 5 * 60 * 1000,
- });
-
- return snapshot ? { market, state: snapshot } : null;
});
- const snapshots = await Promise.all(marketDataPromises);
-
- // Process valid snapshots
- const validPositions = snapshots
- .filter(
- (item): item is NonNullable & { state: NonNullable } =>
- item !== null && item.state !== null,
- )
- .filter((position) => {
- const hasSupply = position.state.supplyShares.toString() !== '0';
- const hasBorrow = position.state.borrowShares.toString() !== '0';
- const hasCollateral = position.state.collateral.toString() !== '0';
- return showEmpty || hasSupply || hasBorrow || hasCollateral;
- })
- .map((position) => ({
- state: position.state,
- market: position.market,
- }));
-
// Update market cache
const marketsToCache = validPositions
.filter((position) => position.market?.uniqueKey && position.market?.morphoBlue?.chain?.id)
diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts
index d2613ade..3924ffbf 100644
--- a/src/hooks/useUserPositionsSummaryData.ts
+++ b/src/hooks/useUserPositionsSummaryData.ts
@@ -2,245 +2,258 @@ import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Address } from 'viem';
import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider';
+import { calculateEarningsFromSnapshot } from '@/utils/interest';
import { SupportedNetworks } from '@/utils/networks';
-import {
- calculateEarningsFromPeriod as calculateEarnings,
- initializePositionsWithEmptyEarnings,
-} from '@/utils/positions';
-import { estimatedBlockNumber } from '@/utils/rpc';
+import { fetchPositionsSnapshots, PositionSnapshot } from '@/utils/positions';
+import { estimatedBlockNumber, getClient } from '@/utils/rpc';
import { MarketPositionWithEarnings } from '@/utils/types';
import useUserPositions, { positionKeys } from './useUserPositions';
import useUserTransactions from './useUserTransactions';
-export type Period = 'day' | 'week' | 'month';
+export type EarningsPeriod = 'all' | 'day' | 'week' | 'month';
-type BlockNumbers = {
- day?: number;
- week?: number;
- month?: number;
-};
-
-type ChainBlockNumbers = Record;
-
-type UseUserPositionsSummaryDataOptions = {
- periods?: Period[];
- chainIds?: SupportedNetworks[];
-};
-
-// Query keys for block numbers and earnings
+// Query keys
export const blockKeys = {
all: ['blocks'] as const,
- chain: (chainId: number) => [...blockKeys.all, chainId] as const,
+ period: (period: EarningsPeriod, chainIds?: string) => [...blockKeys.all, period, chainIds] as const,
};
export const earningsKeys = {
all: ['earnings'] as const,
user: (address: string) => [...earningsKeys.all, address] as const,
- position: (address: string, marketKey: string) =>
- [...earningsKeys.user(address), marketKey] as const,
};
-const fetchBlockNumbers = async (
- periods: Period[] = ['day', 'week', 'month'],
- chainIds?: SupportedNetworks[]
-) => {
- console.log('🔄 [BLOCK NUMBERS] Fetch started for periods:', periods, 'chains:', chainIds ?? 'all');
-
- const now = Date.now() / 1000;
+// Helper to get timestamp for a period
+const getPeriodTimestamp = (period: EarningsPeriod): number => {
+ const now = Math.floor(Date.now() / 1000);
const DAY = 86400;
- const timestamps: Partial> = {};
- if (periods.includes('day')) timestamps.day = now - DAY;
- if (periods.includes('week')) timestamps.week = now - 7 * DAY;
- if (periods.includes('month')) timestamps.month = now - 30 * DAY;
+ switch (period) {
+ case 'all':
+ return 0;
+ case 'day':
+ return now - DAY;
+ case 'week':
+ return now - 7 * DAY;
+ case 'month':
+ return now - 30 * DAY;
+ }
+};
- const newBlockNums = {} as ChainBlockNumbers;
+// Fetch block number for a specific period across chains
+const fetchPeriodBlockNumbers = async (
+ period: EarningsPeriod,
+ chainIds?: SupportedNetworks[]
+): Promise> => {
+ if (period === 'all') return {};
- const allNetworks = Object.values(SupportedNetworks)
- .filter((chainId): chainId is SupportedNetworks => typeof chainId === 'number');
+ const timestamp = getPeriodTimestamp(period);
- // Filter to specific chains if provided
+ const allNetworks = Object.values(SupportedNetworks).filter(
+ (chainId): chainId is SupportedNetworks => typeof chainId === 'number'
+ );
const networksToFetch = chainIds ?? allNetworks;
- // Get block numbers for requested networks and timestamps
+ const blockNumbers: Record = {};
+
await Promise.all(
networksToFetch.map(async (chainId) => {
- const blockNumbers: BlockNumbers = {};
-
- const promises = Object.entries(timestamps).map(async ([period, timestamp]) => {
- const result = await estimatedBlockNumber(chainId, timestamp as number);
- if (result) {
- blockNumbers[period as Period] = result.blockNumber;
- }
- });
-
- await Promise.all(promises);
-
- if (Object.keys(blockNumbers).length > 0) {
- newBlockNums[chainId] = blockNumbers;
- }
- }),
+ const result = await estimatedBlockNumber(chainId, timestamp);
+ if (result) {
+ blockNumbers[chainId] = result.blockNumber;
+ }
+ })
);
- console.log('📊 [BLOCK NUMBERS] Fetch complete');
- return newBlockNums;
+ return blockNumbers;
};
const useUserPositionsSummaryData = (
user: string | undefined,
- options: UseUserPositionsSummaryDataOptions = {}
+ period: EarningsPeriod = 'all',
+ chainIds?: SupportedNetworks[]
) => {
- const { periods = ['day', 'week', 'month'], chainIds } = options;
-
const {
data: positions,
loading: positionsLoading,
isRefetching,
positionsError,
} = useUserPositions(user, true, chainIds);
- const { fetchTransactions } = useUserTransactions();
+ const { fetchTransactions } = useUserTransactions();
const queryClient = useQueryClient();
-
const { customRpcUrls } = useCustomRpcContext();
- // Query for block numbers - cached per period and chain combination
- const { data: blockNums, isLoading: isLoadingBlockNums } = useQuery({
- queryKey: [...blockKeys.all, periods.join(','), chainIds?.join(',') ?? 'all'],
- queryFn: async () => fetchBlockNumbers(periods, chainIds),
- staleTime: 5 * 60 * 1000, // Consider block numbers fresh for 5 minutes
- gcTime: 3 * 60 * 1000, // Keep in cache for 3 minutes
- });
-
- // Create stable query key identifiers
+ // Create stable key for positions
const positionsKey = useMemo(
() => positions?.map(p => `${p.market.uniqueKey}-${p.market.morphoBlue.chain.id}`).sort().join(',') ?? '',
[positions]
);
- const blockNumsKey = useMemo(
- () => {
- if (!blockNums) return '';
- return Object.entries(blockNums)
- .map(([chain, blocks]) => `${chain}:${JSON.stringify(blocks)}`)
- .join(',');
+ // Query for block numbers for the selected period
+ const { data: periodBlockNumbers, isLoading: isLoadingBlocks } = useQuery({
+ queryKey: blockKeys.period(period, chainIds?.join(',')),
+ queryFn: async () => fetchPeriodBlockNumbers(period, chainIds),
+ enabled: period !== 'all',
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ gcTime: 3 * 60 * 1000,
+ });
+
+ // Query for snapshots at the period's block (batched by chain)
+ const { data: periodSnapshots, isLoading: isLoadingSnapshots } = useQuery({
+ queryKey: ['period-snapshots', user, period, positionsKey, JSON.stringify(periodBlockNumbers)],
+ queryFn: async () => {
+ if (!positions || !user) return new Map();
+ if (period === 'all') return new Map();
+ if (!periodBlockNumbers) return new Map();
+
+ // Group positions by chain
+ const positionsByChain = new Map();
+ positions.forEach((pos) => {
+ const chainId = pos.market.morphoBlue.chain.id;
+ const existing = positionsByChain.get(chainId) ?? [];
+ existing.push(pos.market.uniqueKey);
+ positionsByChain.set(chainId, existing);
+ });
+
+ // Batch fetch snapshots for each chain
+ const allSnapshots = new Map();
+ await Promise.all(
+ Array.from(positionsByChain.entries()).map(async ([chainId, marketIds]) => {
+ const blockNumber = periodBlockNumbers[chainId];
+ if (!blockNumber) return;
+
+ const client = getClient(
+ chainId as SupportedNetworks,
+ customRpcUrls[chainId as SupportedNetworks]
+ );
+
+ const snapshots = await fetchPositionsSnapshots(
+ marketIds,
+ user as Address,
+ chainId,
+ blockNumber,
+ client
+ );
+
+ snapshots.forEach((snapshot, marketId) => {
+ allSnapshots.set(marketId.toLowerCase(), snapshot);
+ });
+ })
+ );
+
+ return allSnapshots;
},
- [blockNums]
- );
+ enabled: !!positions && !!user && (period === 'all' || !!periodBlockNumbers),
+ staleTime: 30000,
+ gcTime: 5 * 60 * 1000,
+ });
- // Query for earnings calculations with progressive updates
- const {
- data: positionsWithEarnings,
- isLoading: isLoadingEarningsQuery,
- isFetching: isFetchingEarnings,
- error,
- } = useQuery({
- queryKey: ['positions-earnings', user, positionsKey, blockNumsKey, periods.join(','), chainIds?.join(',') ?? 'all'],
+ // Query for all transactions (independent of period)
+ const { data: allTransactions, isLoading: isLoadingTransactions } = useQuery({
+ queryKey: ['user-transactions-summary', user, positionsKey, chainIds?.join(',') ?? 'all'],
queryFn: async () => {
- if (!positions || !user || !blockNums) {
- console.log('⚠️ [EARNINGS] Missing required data, returning empty earnings');
- return {
- positions: [] as MarketPositionWithEarnings[],
- fetched: false,
- };
- }
+ if (!positions || !user) return [];
- console.log('🔄 [EARNINGS] Starting calculation for', positions.length, 'positions');
+ // Deduplicate chain IDs to avoid fetching same network multiple times
+ const uniqueChainIds = chainIds ?? [...new Set(positions.map(p => p.market.morphoBlue.chain.id as SupportedNetworks))];
- // Calculate earnings for each position
- const positionPromises = positions.map(async (position) => {
- console.log('📈 [EARNINGS] Calculating for market:', position.market.uniqueKey);
+ const result = await fetchTransactions({
+ userAddress: [user],
+ marketUniqueKeys: positions.map(p => p.market.uniqueKey),
+ chainIds: uniqueChainIds,
+ });
- const chainId = position.market.morphoBlue.chain.id as SupportedNetworks;
+ return result?.items ?? [];
+ },
+ enabled: !!positions && !!user,
+ staleTime: 60000, // 1 minute
+ gcTime: 5 * 60 * 1000,
+ });
- const history = await fetchTransactions({
- userAddress: [user],
- marketUniqueKeys: [position.market.uniqueKey],
- chainIds: [chainId], // Only fetch transactions for this position's chain!
- });
+ // Calculate earnings from snapshots + transactions
+ const positionsWithEarnings = useMemo((): MarketPositionWithEarnings[] => {
+ if (!positions) return [];
- const blockNumbers = blockNums[chainId];
+ // Don't calculate if transactions haven't loaded yet - return positions with 0 earnings
+ // This prevents incorrect calculations when withdraws/deposits aren't counted
+ if (!allTransactions) {
+ return positions.map(p => ({ ...p, earned: '0' }));
+ }
- const customRpcUrl = customRpcUrls[chainId] ?? undefined;
+ // Don't calculate if snapshots haven't loaded yet for non-'all' periods
+ // Without the starting balance, earnings calculation will be incorrect
+ if (period !== 'all' && !periodSnapshots) {
+ return positions.map(p => ({ ...p, earned: '0' }));
+ }
- const earned = await calculateEarnings(
- position,
- history.items,
- user as Address,
- chainId,
- blockNumbers,
- customRpcUrl,
- );
+ const now = Math.floor(Date.now() / 1000);
+ const startTimestamp = getPeriodTimestamp(period);
- console.log('✅ [EARNINGS] Completed for market:', position.market.uniqueKey);
+ return positions.map((position) => {
+ const currentBalance = BigInt(position.state.supplyAssets);
+ const marketId = position.market.uniqueKey;
+ const marketIdLower = marketId.toLowerCase();
- return {
- ...position,
- earned,
- };
- });
+ // Get past balance from snapshot (0 for lifetime)
+ const pastSnapshot = periodSnapshots?.get(marketIdLower);
+ const pastBalance = pastSnapshot ? BigInt(pastSnapshot.supplyAssets) : 0n;
- // Wait for all earnings calculations to complete
- const positionsWithCalculatedEarnings = await Promise.all(positionPromises);
+ // Filter transactions for this market (case-insensitive comparison)
+ const marketTxs = (allTransactions ?? []).filter(
+ (tx) => tx.data?.market?.uniqueKey?.toLowerCase() === marketIdLower
+ );
+
+ // Calculate earnings
+ const earnings = calculateEarningsFromSnapshot(
+ currentBalance,
+ pastBalance,
+ marketTxs,
+ startTimestamp,
+ now
+ );
- console.log('📊 [EARNINGS] All earnings calculations complete');
- return {
- positions: positionsWithCalculatedEarnings,
- fetched: true,
- };
- },
- placeholderData: (prev) => {
- // If we have positions but no earnings data yet, initialize with empty earnings
- if (positions?.length) {
- console.log('📋 [EARNINGS] Using placeholder data with empty earnings');
- return {
- positions: initializePositionsWithEmptyEarnings(positions),
- fetched: false,
- };
- }
- // If we have previous data, keep it during transitions
- if (prev) {
- console.log('📋 [EARNINGS] Keeping previous earnings data during transition');
- return prev;
- }
return {
- positions: [] as MarketPositionWithEarnings[],
- fetched: false,
+ ...position,
+ earned: earnings.earned.toString(),
};
- },
- enabled: !!positions && !!user && !!blockNums,
- gcTime: 5 * 60 * 1000,
- staleTime: 30000,
- });
+ });
+ }, [positions, periodSnapshots, allTransactions, period]);
const refetch = async (onSuccess?: () => void) => {
try {
- // Do not invalidate block numbers: keep the old block numbers
- // await queryClient.invalidateQueries({ queryKey: blockKeys.all });
-
- // Invalidate positions initial data
+ // Invalidate positions
await queryClient.invalidateQueries({ queryKey: positionKeys.initialData(user ?? '') });
- // Invalidate positions enhanced data (invalidate all for this user)
await queryClient.invalidateQueries({ queryKey: ['enhanced-positions', user] });
- // Invalidate earnings query
- await queryClient.invalidateQueries({ queryKey: ['positions-earnings', user] });
- if (onSuccess) {
- onSuccess();
- }
+ // Invalidate snapshots
+ await queryClient.invalidateQueries({ queryKey: ['period-snapshots', user] });
+ // Invalidate transactions
+ await queryClient.invalidateQueries({ queryKey: ['user-transactions-summary', user] });
+
+ onSuccess?.();
} catch (refetchError) {
console.error('Error refetching positions:', refetchError);
}
};
- const isEarningsLoading = isLoadingBlockNums || isLoadingEarningsQuery || isFetchingEarnings;
+ const isEarningsLoading = isLoadingBlocks || isLoadingSnapshots || isLoadingTransactions;
+
+ // Detailed loading states for UI
+ const loadingStates = {
+ positions: positionsLoading,
+ blocks: isLoadingBlocks,
+ snapshots: isLoadingSnapshots,
+ transactions: isLoadingTransactions,
+ };
return {
- positions: positionsWithEarnings?.positions,
+ positions: positionsWithEarnings,
isPositionsLoading: positionsLoading,
isEarningsLoading,
isRefetching,
- error: error ?? positionsError,
+ error: positionsError,
refetch,
+ loadingStates,
};
};
diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts
index 61916586..80b1fd2e 100644
--- a/src/hooks/useVaultPage.ts
+++ b/src/hooks/useVaultPage.ts
@@ -74,7 +74,8 @@ export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVau
!needsAdapterDeployment && morphoMarketV1Adapter !== zeroAddress
? morphoMarketV1Adapter
: undefined,
- { periods: ['day'], chainIds: [chainId] }
+ 'day',
+ [chainId]
);
// Calculate vault APY from adapter positions (weighted average)
@@ -108,9 +109,9 @@ export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVau
let total = 0n;
adapterPositions.forEach((position) => {
- if (position.earned?.last24hEarned) {
+ if (position.earned) {
// Sum up all earnings (assumes they're in raw bigint string format)
- total += BigInt(position.earned.last24hEarned);
+ total += BigInt(position.earned);
}
});
diff --git a/src/utils/markets.ts b/src/utils/markets.ts
index ca3d484c..1955076e 100644
--- a/src/utils/markets.ts
+++ b/src/utils/markets.ts
@@ -38,3 +38,4 @@ export const monarchWhitelistedMarkets: WhitelistMarketData[] = [
offsetWarnings: ['unrecognized_collateral_asset'],
},
];
+
diff --git a/src/utils/positions.ts b/src/utils/positions.ts
index e31516b8..d5f60b7b 100644
--- a/src/utils/positions.ts
+++ b/src/utils/positions.ts
@@ -1,14 +1,10 @@
import { Address, formatUnits, PublicClient } from 'viem';
import morphoABI from '@/abis/morpho';
-import { calculateEarningsFromSnapshot } from './interest';
import { getMorphoAddress } from './morpho';
import { SupportedNetworks } from './networks';
-import { getClient } from './rpc';
import {
MarketPosition,
MarketPositionWithEarnings,
- PositionEarnings,
- UserTransaction,
GroupedPosition,
} from './types';
@@ -70,102 +66,163 @@ function convertSharesToAssets(shares: bigint, totalAssets: bigint, totalShares:
}
/**
- * Fetches a position snapshot for a specific market, user, and block number using a PublicClient
+ * Fetches position snapshots for multiple markets using multicall for efficiency.
+ * All markets must be on the same chain, for the same user, at the same block.
*
- * @param marketId - The unique ID of the market
+ * @param marketIds - Array of market unique IDs
* @param userAddress - The user's address
* @param chainId - The chain ID of the network
- * @param blockNumber - The block number to fetch the position at (0 for latest)
+ * @param blockNumber - The block number to fetch positions at (0 for latest)
* @param client - The viem PublicClient to use for the request
- * @returns The position snapshot or null if there was an error
+ * @returns Map of marketId to PositionSnapshot
*/
-export async function fetchPositionSnapshot(
- marketId: string,
+export async function fetchPositionsSnapshots(
+ marketIds: string[],
userAddress: Address,
chainId: number,
blockNumber: number,
client: PublicClient,
-): Promise {
+): Promise