diff --git a/.env.local.example b/.env.local.example index c288e3af..15b545c8 100644 --- a/.env.local.example +++ b/.env.local.example @@ -2,4 +2,7 @@ NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID="GET_ID_FROM_WALLET_CONNET" # See https://cloud.walletconnect.com ENVIRONMENT=localhost -NEXT_PUBLIC_ALCHEMY_API_KEY= \ No newline at end of file +NEXT_PUBLIC_ALCHEMY_API_KEY= + +# used for querying block with given timestamp +ETHERSCAN_API_KEY= \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index ab3e12ca..919ac22c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -121,8 +121,8 @@ module.exports = { // APIs '@typescript-eslint/prefer-includes': 'error', '@typescript-eslint/prefer-nullish-coalescing': 'error', - '@typescript-eslint/prefer-optional-chain': 'error', '@typescript-eslint/prefer-string-starts-ends-with': 'error', + '@typescript-eslint/prefer-optional-chain': 'warn', // Hard to migrate // Errors for all try/catch blocks and any types from third-parties diff --git a/app/api/block/route.ts b/app/api/block/route.ts index d76e3491..a938b5d5 100644 --- a/app/api/block/route.ts +++ b/app/api/block/route.ts @@ -4,6 +4,27 @@ import { SmartBlockFinder } from '@/utils/blockFinder'; import { SupportedNetworks } from '@/utils/networks'; import { mainnetClient, baseClient } from '@/utils/rpc'; +const ETHERSCAN_API_KEY = process.env.ETHERSCAN_API_KEY; + +async function getBlockFromEtherscan(timestamp: number, chainId: number): Promise { + try { + const response = await fetch( + `https://api.etherscan.io/v2/api?chainid=${chainId}&module=block&action=getblocknobytime×tamp=${timestamp}&closest=before&apikey=${ETHERSCAN_API_KEY}`, + ); + + const data = (await response.json()) as { status: string; message: string; result: string }; + + if (data.status === '1' && data.message === 'OK') { + return parseInt(data.result); + } + + return null; + } catch (error) { + console.error('Etherscan API error:', error); + return null; + } +} + export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; @@ -18,16 +39,31 @@ export async function GET(request: NextRequest) { } const numericChainId = parseInt(chainId); + const numericTimestamp = parseInt(timestamp); + + // Fallback to SmartBlockFinder const client = numericChainId === SupportedNetworks.Mainnet ? mainnetClient : baseClient; + // Try Etherscan API first + const etherscanBlock = await getBlockFromEtherscan(numericTimestamp, numericChainId); + if (etherscanBlock !== null) { + // For Etherscan results, we need to fetch the block to get its timestamp + const block = await client.getBlock({ blockNumber: BigInt(etherscanBlock) }); + + return NextResponse.json({ + blockNumber: Number(block.number), + timestamp: Number(block.timestamp), + }); + } else { + console.log('etherscanBlock is null', timestamp, chainId); + } + if (!client) { return NextResponse.json({ error: 'Unsupported chain ID' }, { status: 400 }); } const finder = new SmartBlockFinder(client as any as PublicClient, numericChainId); - - console.log('GET functino trying to find nearest block', timestamp); - const block = await finder.findNearestBlock(parseInt(timestamp)); + const block = await finder.findNearestBlock(numericTimestamp); return NextResponse.json({ blockNumber: Number(block.number), diff --git a/app/api/positions/historical/route.ts b/app/api/positions/historical/route.ts index d0a607d7..c897ce73 100644 --- a/app/api/positions/historical/route.ts +++ b/app/api/positions/historical/route.ts @@ -143,13 +143,6 @@ export async function GET(request: NextRequest) { const userAddress = searchParams.get('userAddress'); const chainId = parseInt(searchParams.get('chainId') ?? '1'); - // console.log(`Historical position request:`, { - // blockNumber, - // marketId, - // userAddress, - // chainId, - // }); - if (!marketId || !userAddress || (!blockNumber && blockNumber !== 0)) { console.error('Missing required parameters:', { blockNumber: !!blockNumber, @@ -162,14 +155,6 @@ export async function GET(request: NextRequest) { // Get position data at the specified blockNumber const position = await getPositionAtBlock(marketId, userAddress, blockNumber, chainId); - // console.log(`Successfully retrieved historical position data:`, { - // blockNumber, - // marketId, - // userAddress, - // chainId, - // position, - // }); - return NextResponse.json({ position, }); diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index f9236c46..4ad9c3de 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -60,7 +60,7 @@ export default function Positions() { }); const handleRefetch = () => { - refetch(() => toast.info('Data refreshed', { icon: 🚀 })); + void refetch(() => toast.info('Data refreshed', { icon: 🚀 })); }; return ( @@ -112,7 +112,7 @@ export default function Positions() { setShowWithdrawModal(false); setSelectedPosition(null); }} - refetch={refetch} + refetch={() => void refetch()} /> )} @@ -171,7 +171,7 @@ export default function Positions() { setShowWithdrawModal={setShowWithdrawModal} setShowSupplyModal={setShowSupplyModal} setSelectedPosition={setSelectedPosition} - refetch={refetch} + refetch={() => void refetch()} isRefetching={isRefetching} isLoadingEarnings={isEarningsLoading} rebalancerInfo={rebalancerInfo} diff --git a/package.json b/package.json index 466c1a00..c6ed05bc 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "@radix-ui/react-navigation-menu": "^1.1.4", "@rainbow-me/rainbowkit": "2", "@react-spring/web": "^9.7.3", - "@tanstack/react-query": "^5.20.1", + "@tanstack/react-query": "^5.69.0", + "@tanstack/react-query-devtools": "^5.69.0", "@types/react-table": "^7.7.20", "@uniswap/permit2-sdk": "^1.2.1", "abitype": "^0.10.3", diff --git a/src/components/providers/ClientProviders.tsx b/src/components/providers/ClientProviders.tsx index de5389a6..b4026c4a 100644 --- a/src/components/providers/ClientProviders.tsx +++ b/src/components/providers/ClientProviders.tsx @@ -1,26 +1,42 @@ 'use client'; import { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { MarketsProvider } from '@/contexts/MarketsContext'; import { OnboardingProvider } from 'app/positions/components/onboarding/OnboardingContext'; import { ConnectRedirectProvider } from './ConnectRedirectProvider'; import { ThemeProviders } from './ThemeProvider'; import { TokenProvider } from './TokenProvider'; +// Create a client with default configuration +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30000, // Default stale time of 30 seconds + retry: 2, + refetchOnWindowFocus: false, + }, + }, +}); + type ClientProvidersProps = { children: ReactNode; }; export function ClientProviders({ children }: ClientProvidersProps) { return ( - - - - - {children} - - - - + + + + + + {children} + + + + + + ); } diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 1d201e2c..dd7b4953 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -1,182 +1,270 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Address } from 'viem'; import { userPositionsQuery } from '@/graphql/queries'; import { SupportedNetworks } from '@/utils/networks'; -import { fetchPositionSnapshot } from '@/utils/positions'; -import { MarketPosition } from '@/utils/types'; +import { fetchPositionSnapshot, type PositionSnapshot } from '@/utils/positions'; +import { MarketPosition, Market } from '@/utils/types'; import { URLS } from '@/utils/urls'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; import { useUserMarketsCache } from '../hooks/useUserMarketsCache'; import { useMarkets } from './useMarkets'; -const useUserPositions = (user: string | undefined, showEmpty = false) => { - const [loading, setLoading] = useState(true); - const [isRefetching, setIsRefetching] = useState(false); - const [data, setData] = useState([]); - const [positionsError, setPositionsError] = useState(null); +type UserPositionsResponse = { + marketPositions: MarketPosition[]; + usedMarkets: { + marketUniqueKey: string; + chainId: number; + }[]; +}; - const { markets } = useMarkets(); +type MarketToFetch = { + marketKey: string; + chainId: number; + market: Market; + existingState: PositionSnapshot | null; +}; - const { getUserMarkets, batchAddUserMarkets } = useUserMarketsCache(); +type EnhancedMarketPosition = { + state: PositionSnapshot; + market: Market & { warningsWithDetail: ReturnType }; +}; - const fetchData = useCallback( - async (isRefetch = false, onSuccess?: () => void) => { - if (!user) { - console.error('Missing user address'); - setLoading(false); - setIsRefetching(false); - return; - } +type SnapshotResult = { + market: Market; + state: PositionSnapshot | null; +} | null; - try { - if (isRefetch) { - setIsRefetching(true); - } else { - setLoading(true); - } +type ValidMarketPosition = MarketPosition & { + market: Market & { + uniqueKey: string; + morphoBlue: { chain: { id: number } }; + }; +}; - setPositionsError(null); - - // Fetch position data from both networks - const [responseMainnet, responseBase] = await Promise.all([ - fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: userPositionsQuery, - variables: { - address: user.toLowerCase(), - chainId: SupportedNetworks.Mainnet, - }, - }), - }), - fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: userPositionsQuery, - variables: { - address: user.toLowerCase(), - chainId: SupportedNetworks.Base, - }, - }), - }), - ]); +// Query keys for caching +export const positionKeys = { + all: ['positions'] as const, + user: (address: string) => [...positionKeys.all, address] as const, + snapshot: (marketKey: string, userAddress: string, chainId: number) => + [...positionKeys.all, 'snapshot', marketKey, userAddress, chainId] as const, + enhanced: (user: string | undefined, data: UserPositionsResponse | undefined) => + ['enhanced-positions', user, data] as const, +}; - const result1 = await responseMainnet.json(); - const result2 = await responseBase.json(); +const fetchUserPositions = async ( + user: string, + getUserMarkets: () => { marketUniqueKey: string; chainId: number }[], +): Promise => { + console.log('🔄 Fetching user positions for:', user); - const unknownUsedMarkets = getUserMarkets(); + const [responseMainnet, responseBase] = await Promise.all([ + fetch(URLS.MORPHO_BLUE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: userPositionsQuery, + variables: { + address: user.toLowerCase(), + chainId: SupportedNetworks.Mainnet, + }, + }), + }), + fetch(URLS.MORPHO_BLUE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: userPositionsQuery, + variables: { + address: user.toLowerCase(), + chainId: SupportedNetworks.Base, + }, + }), + }), + ]); - const marketPositions: MarketPosition[] = []; + const [result1, result2] = await Promise.all([responseMainnet.json(), responseBase.json()]); - // Collect positions - for (const result of [result1, result2]) { - if (result.data?.userByAddress) { - marketPositions.push( - ...(result.data.userByAddress.marketPositions as MarketPosition[]), - ); - } - } + console.log('📊 Received positions data from both networks'); + + const usedMarkets = getUserMarkets(); + const marketPositions: MarketPosition[] = []; + + // Collect positions + for (const result of [result1, result2]) { + if (result.data?.userByAddress?.marketPositions) { + marketPositions.push(...(result.data.userByAddress.marketPositions as MarketPosition[])); + } + } + + return { marketPositions, usedMarkets }; +}; + +const useUserPositions = (user: string | undefined, showEmpty = false) => { + const queryClient = useQueryClient(); + const { markets } = useMarkets(); + const { getUserMarkets, batchAddUserMarkets } = useUserMarketsCache(); + + // Main query for user positions + const { + data: positionsData, + isLoading: isLoadingPositions, + isRefetching: isRefetchingPositions, + error: positionsError, + refetch: refetchPositions, + } = useQuery({ + queryKey: positionKeys.user(user ?? ''), + queryFn: async () => { + if (!user) throw new Error('Missing user address'); + return fetchUserPositions(user, getUserMarkets); + }, + enabled: !!user, + staleTime: 30000, // Consider data fresh for 30 seconds + gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + }); + + // Query for position snapshots + const { data: enhancedPositions, isRefetching: isRefetchingEnhanced } = useQuery< + EnhancedMarketPosition[] + >({ + queryKey: positionKeys.enhanced(user, positionsData), + queryFn: async () => { + if (!positionsData || !user) return []; + + console.log('🔄 Fetching position snapshots'); + + const { marketPositions, usedMarkets } = positionsData; - for (const market of unknownUsedMarkets) { - // check if they're already in the marketPositions array + // We need to fetch snapshots for ALL markets - both from API and used ones + const knownMarkets = marketPositions + .filter( + (position): position is ValidMarketPosition => + position.market?.uniqueKey !== undefined && + position.market?.morphoBlue?.chain?.id !== undefined, + ) + .map( + (position): MarketToFetch => ({ + marketKey: position.market.uniqueKey, + chainId: position.market.morphoBlue.chain.id, + market: position.market, + existingState: position.state, + }), + ); + + const marketsToRescan = usedMarkets + .filter((market) => { + return !marketPositions.find( + (position) => + position.market?.uniqueKey?.toLowerCase() === market.marketUniqueKey.toLowerCase() && + position.market?.morphoBlue?.chain?.id === market.chainId, + ); + }) + .map((market) => { + const marketWithDetails = markets.find( + (m) => + m.uniqueKey?.toLowerCase() === market.marketUniqueKey.toLowerCase() && + m.morphoBlue?.chain?.id === market.chainId, + ); if ( - marketPositions.find( - (position) => - position.market.uniqueKey.toLowerCase() === market.marketUniqueKey.toLowerCase() && - position.market.morphoBlue.chain.id === market.chainId, - ) + !marketWithDetails || + !marketWithDetails.uniqueKey || + !marketWithDetails.morphoBlue?.chain?.id ) { - continue; + return null; } + return { + marketKey: market.marketUniqueKey, + chainId: market.chainId, + market: marketWithDetails, + existingState: null, + } as MarketToFetch; + }) + .filter((item): item is MarketToFetch => item !== null); - // skip markets we can't find - const marketWithDetails = markets.find((m) => m.uniqueKey === market.marketUniqueKey); - if (!marketWithDetails) { - continue; - } + const allMarketsToFetch: MarketToFetch[] = [...knownMarkets, ...marketsToRescan]; - const currentSnapshot = await fetchPositionSnapshot( - market.marketUniqueKey, - user as Address, - market.chainId, - 0, - ); + console.log(`🔄 Fetching snapshots for ${allMarketsToFetch.length} markets`); - if (currentSnapshot) { - marketPositions.push({ - market: marketWithDetails, - state: currentSnapshot, + // Fetch snapshots in parallel using React Query's built-in caching + const snapshots = await Promise.all( + allMarketsToFetch.map( + async ({ marketKey, chainId, market, existingState }): Promise => { + const snapshot = await queryClient.fetchQuery({ + queryKey: positionKeys.snapshot(marketKey, user, chainId), + queryFn: async () => fetchPositionSnapshot(marketKey, user as Address, chainId, 0), + staleTime: 30000, + gcTime: 5 * 60 * 1000, }); - } - } - const enhancedPositions = await Promise.all( - marketPositions - .filter( - (position: MarketPosition) => - showEmpty || position.state.supplyShares.toString() !== '0', - ) - .map(async (position: MarketPosition) => { - // fetch real market position to be accurate - const currentSnapshot = await fetchPositionSnapshot( - position.market.uniqueKey, - user as Address, - position.market.morphoBlue.chain.id, - 0, - ); - - const accuratePositionState = currentSnapshot ? currentSnapshot : position.state; - - // Process positions and calculate earnings - return { - state: accuratePositionState, - market: { - ...position.market, - warningsWithDetail: getMarketWarningsWithDetail(position.market), - }, - }; - }), - ); + if (!snapshot && !existingState) return null; - setData(enhancedPositions); + return { + market, + state: snapshot ?? existingState, + }; + }, + ), + ); - batchAddUserMarkets( - marketPositions.map((position) => ({ - marketUniqueKey: position.market.uniqueKey, - chainId: position.market.morphoBlue.chain.id, - })), - ); + console.log('📊 Received position snapshots'); + + // Filter out null results and process positions + const validPositions = snapshots + .filter( + (item): item is NonNullable & { state: NonNullable } => + item !== null && item.state !== null, + ) + .filter((position) => showEmpty || position.state.supplyShares.toString() !== '0') + .map((position) => ({ + state: position.state, + market: { + ...position.market, + warningsWithDetail: getMarketWarningsWithDetail(position.market), + }, + })); + + // Update market cache with all valid positions + const marketsToCache = validPositions + .filter((position) => position.market?.uniqueKey && position.market?.morphoBlue?.chain?.id) + .map((position) => ({ + marketUniqueKey: position.market.uniqueKey, + chainId: position.market.morphoBlue.chain.id, + })); - onSuccess?.(); - } catch (err) { - console.error('Error fetching positions:', err); - setPositionsError(err); - } finally { - setLoading(false); - setIsRefetching(false); + if (marketsToCache.length > 0) { + batchAddUserMarkets(marketsToCache); + } + + return validPositions; + }, + enabled: !!positionsData && !!user, + }); + + const refetch = useCallback( + async (onSuccess?: () => void) => { + try { + await refetchPositions(); + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Error refetching positions:', error); } }, - [user, showEmpty, markets, batchAddUserMarkets, getUserMarkets], + [refetchPositions], ); - useEffect(() => { - void fetchData(); - }, [fetchData]); + // Consider refetching true if either query is refetching + const isRefetching = isRefetchingPositions || isRefetchingEnhanced; return { - data, - loading, + data: enhancedPositions ?? [], + loading: isLoadingPositions, isRefetching, positionsError, - refetch: (onSuccess?: () => void) => void fetchData(true, onSuccess), + refetch, }; }; diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 87078b47..65ddbf58 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Address } from 'viem'; import { SupportedNetworks } from '@/utils/networks'; import { @@ -20,148 +21,181 @@ type ChainBlockNumbers = { [K in SupportedNetworks]: BlockNumbers; }; +// Query keys for block numbers and earnings +export const blockKeys = { + all: ['blocks'] as const, + chain: (chainId: number) => [...blockKeys.all, chainId] 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 () => { + console.log('🔄 [BLOCK NUMBERS] Initial fetch started'); + + const now = Date.now() / 1000; + const DAY = 86400; + const timestamps = { + day: now - DAY, + week: now - 7 * DAY, + month: now - 30 * DAY, + }; + + const newBlockNums = {} as ChainBlockNumbers; + + // Get block numbers for each network and timestamp + await Promise.all( + Object.values(SupportedNetworks) + .filter((chainId): chainId is SupportedNetworks => typeof chainId === 'number') + .map(async (chainId) => { + const [day, week, month] = await Promise.all([ + estimatedBlockNumber(chainId, timestamps.day), + estimatedBlockNumber(chainId, timestamps.week), + estimatedBlockNumber(chainId, timestamps.month), + ]); + + if (day && week && month) { + newBlockNums[chainId] = { + day: day.blockNumber, + week: week.blockNumber, + month: month.blockNumber, + }; + } + }), + ); + + console.log('📊 [BLOCK NUMBERS] Fetch complete'); + return newBlockNums; +}; + const useUserPositionsSummaryData = (user: string | undefined) => { + const [hasInitialData, setHasInitialData] = useState(false); + const { + data: positions, loading: positionsLoading, isRefetching, - data: positions, positionsError, - refetch, + refetch: refetchPositions, } = useUserPositions(user, true); const { fetchTransactions } = useUserTransactions(); - const [positionsWithEarnings, setPositionsWithEarnings] = useState( - [], - ); - const [blockNums, setBlockNums] = useState(); - const [isLoadingBlockNums, setIsLoadingBlockNums] = useState(false); - const [isLoadingEarnings, setIsLoadingEarnings] = useState(false); - const [error, setError] = useState(null); + // Query for block numbers - this runs once and is cached + const { data: blockNums, isLoading: isLoadingBlockNums } = useQuery({ + queryKey: blockKeys.all, + queryFn: fetchBlockNumbers, + staleTime: 5 * 60 * 1000, // Consider block numbers fresh for 5 minutes + gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes + }); - // Loading state for positions that doesn't include earnings calculation - const isPositionsLoading = positionsLoading; + // Query for earnings calculations with progressive updates + const { + data: positionsWithEarnings, + isLoading: isLoadingEarningsQuery, + isFetching: isFetchingEarnings, + error, + } = useQuery({ + queryKey: ['positions-earnings', user, positions, blockNums], + queryFn: async () => { + if (!positions || !user || !blockNums) { + console.log('⚠️ [EARNINGS] Missing required data, returning empty earnings'); + return [] as MarketPositionWithEarnings[]; + } - // Loading state that combines all loading states (used for earnings) - const isEarningsLoading = isLoadingBlockNums || isLoadingEarnings; + console.log('🔄 [EARNINGS] Starting calculation for', positions.length, 'positions'); - useEffect(() => { - const fetchBlockNums = async () => { - try { - setIsLoadingBlockNums(true); - setError(null); - - const now = Date.now() / 1000; - const DAY = 86400; - const timestamps = { - day: now - DAY, - week: now - 7 * DAY, - month: now - 30 * DAY, - }; + // Calculate earnings for each position + const positionPromises = positions.map(async (position) => { + console.log('📈 [EARNINGS] Calculating for market:', position.market.uniqueKey); - const newBlockNums = {} as ChainBlockNumbers; - - // Get block numbers for each network and timestamp - await Promise.all( - Object.values(SupportedNetworks) - .filter((chainId): chainId is SupportedNetworks => typeof chainId === 'number') - .map(async (chainId) => { - const [day, week, month] = await Promise.all([ - estimatedBlockNumber(chainId, timestamps.day), - estimatedBlockNumber(chainId, timestamps.week), - estimatedBlockNumber(chainId, timestamps.month), - ]); - - if (day && week && month) { - newBlockNums[chainId] = { - day: day.blockNumber, - week: week.blockNumber, - month: month.blockNumber, - }; - } - }), - ); + const history = await fetchTransactions({ + userAddress: [user], + marketUniqueKeys: [position.market.uniqueKey], + }); - setBlockNums(newBlockNums); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch block numbers')); - } finally { - setIsLoadingBlockNums(false); - } - }; + const chainId = position.market.morphoBlue.chain.id as SupportedNetworks; + const blockNumbers = blockNums[chainId]; + + const earned = await calculateEarnings( + position, + history.items, + user as Address, + chainId, + blockNumbers, + ); - void fetchBlockNums(); - }, []); + console.log('✅ [EARNINGS] Completed for market:', position.market.uniqueKey); - // Create positions with empty earnings as soon as positions are loaded + return { + ...position, + earned, + }; + }); + + // Wait for all earnings calculations to complete + const positionsWithCalculatedEarnings = await Promise.all(positionPromises); + + console.log('📊 [EARNINGS] All earnings calculations complete'); + return positionsWithCalculatedEarnings; + }, + 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 initializePositionsWithEmptyEarnings(positions); + } + // If we have previous data, keep it during transitions + if (prev) { + console.log('📋 [EARNINGS] Keeping previous earnings data during transition'); + return prev; + } + return [] as MarketPositionWithEarnings[]; + }, + enabled: !!positions && !!user && !!blockNums, + gcTime: 5 * 60 * 1000, + staleTime: 30000, + }); + + // Update hasInitialData when we first get positions with earnings useEffect(() => { - if (positions && positions.length > 0) { - // Initialize positions with empty earnings data to display immediately - setPositionsWithEarnings(initializePositionsWithEmptyEarnings(positions)); + if (positionsWithEarnings && positionsWithEarnings.length > 0 && !hasInitialData) { + setHasInitialData(true); } - }, [positions]); + }, [positionsWithEarnings, hasInitialData]); - // Calculate real earnings in the background - useEffect(() => { - const updatePositionsWithEarnings = async () => { - try { - if (!positions || !user || !blockNums) return; - - setIsLoadingEarnings(true); - setError(null); - - // Process positions one by one to update earnings progressively - // Potential issue: too slow, parallel processing might be better - for (const position of positions) { - const history = await fetchTransactions({ - userAddress: [user], - marketUniqueKeys: [position.market.uniqueKey], - }); - - const chainId = position.market.morphoBlue.chain.id as SupportedNetworks; - const blockNumbers = blockNums[chainId]; - - const earned = await calculateEarnings( - position, - history.items, - user as Address, - chainId, - blockNumbers, - ); - - // Update this single position with earnings - setPositionsWithEarnings((prev) => { - const updatedPositions = [...prev]; - const positionIndex = updatedPositions.findIndex( - (p) => - p.market.uniqueKey === position.market.uniqueKey && - p.market.morphoBlue.chain.id === position.market.morphoBlue.chain.id, - ); - - if (positionIndex !== -1) { - updatedPositions[positionIndex] = { - ...updatedPositions[positionIndex], - earned, - }; - } - - return updatedPositions; - }); - } - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to calculate earnings')); - } finally { - setIsLoadingEarnings(false); + const refetch = async (onSuccess?: () => void) => { + try { + await refetchPositions(); + if (onSuccess) { + onSuccess(); } - }; + } catch (refetchError) { + console.error('Error refetching positions:', refetchError); + } + }; + + // Consider loading if either: + // 1. We haven't received initial data yet + // 2. Positions are still loading initially + // 3. We have positions but no earnings data yet + const isPositionsLoading = + !hasInitialData || positionsLoading || (!!positions?.length && !positionsWithEarnings?.length); - void updatePositionsWithEarnings(); - }, [positions, user, blockNums, fetchTransactions]); + // Consider earnings loading if: + // 1. Block numbers are loading + // 2. Initial earnings query is loading + // 3. Earnings are being fetched/calculated (even if we have placeholder data) + const isEarningsLoading = isLoadingBlockNums || isLoadingEarningsQuery || isFetchingEarnings; return { - positions: positionsWithEarnings, - isPositionsLoading, // For initial load of positions only - isEarningsLoading, // For earnings calculation + positions: positionsWithEarnings ?? [], + isPositionsLoading, + isEarningsLoading, isRefetching, error: error ?? positionsError, refetch, diff --git a/src/utils/interest.ts b/src/utils/interest.ts index 439b3c90..c721cdf4 100644 --- a/src/utils/interest.ts +++ b/src/utils/interest.ts @@ -23,8 +23,6 @@ export function calculateEarningsFromSnapshot( .filter((tx) => Number(tx.timestamp) > start && Number(tx.timestamp) < end) .sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1)); - console.log('txsWithinPeriod', txsWithinPeriod.length); - const depositsAfter = txsWithinPeriod .filter((tx) => tx.type === UserTxTypes.MarketSupply) .reduce((sum, tx) => sum + BigInt(tx.data?.assets || '0'), 0n); diff --git a/src/utils/positions.ts b/src/utils/positions.ts index 9cc53165..b8725f78 100644 --- a/src/utils/positions.ts +++ b/src/utils/positions.ts @@ -45,6 +45,8 @@ export async function fetchPositionSnapshot( blockNumber: number, ): Promise { try { + console.log('fetchPositionSnapshot called', marketId, userAddress, chainId, blockNumber); + // Fetch the position at the specified block number const positionResponse = await fetch( `/api/positions/historical?` + diff --git a/yarn.lock b/yarn.lock index a4502061..f68090e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7575,21 +7575,40 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.51.17": - version: 5.51.17 - resolution: "@tanstack/query-core@npm:5.51.17" - checksum: 10c0/e5df9399be4085c48c3440f00ee59f43186ecfd4bb19e4c59dfe57176a9e62657182305fad69e1da1cb1915fd23337c58b5febb41d59f4bb4498f950df81a318 +"@tanstack/query-core@npm:5.69.0": + version: 5.69.0 + resolution: "@tanstack/query-core@npm:5.69.0" + checksum: 10c0/8ad87046d1a8c18773de10e74d7fef2093cdbe9734e76460945048c00a219ae3d2421eab2b4d54340f3f491813f99ceef7d2849827d95490c8b67de1b87934ae languageName: node linkType: hard -"@tanstack/react-query@npm:^5.20.1": - version: 5.51.18 - resolution: "@tanstack/react-query@npm:5.51.18" +"@tanstack/query-devtools@npm:5.67.2": + version: 5.67.2 + resolution: "@tanstack/query-devtools@npm:5.67.2" + checksum: 10c0/85172bb7cbca204e62507ffb72add4ff8f204b034ea0c6bb82cc44a00756ab196bd8a688b5e857911274aa5e7c467cdda6a393ebce6ba36ce8d471d5b52060fb + languageName: node + linkType: hard + +"@tanstack/react-query-devtools@npm:^5.69.0": + version: 5.69.0 + resolution: "@tanstack/react-query-devtools@npm:5.69.0" dependencies: - "@tanstack/query-core": "npm:5.51.17" + "@tanstack/query-devtools": "npm:5.67.2" peerDependencies: - react: ^18.0.0 - checksum: 10c0/bbf29af19650626c5bee9c6ec2fd158dadf38ef36ebd75b846d2b41869574f0a6c02a98628d91e2d051e88a2a804bf3cb55864050f3dea0ce165a9538b7d6404 + "@tanstack/react-query": ^5.69.0 + react: ^18 || ^19 + checksum: 10c0/e74937f68f205227d3a1bc616daeb4908de6c09426851b64c96b48a2f7468abbfe8a9509eac5c808836f63b0ae5faaba4229e7d1fed5e00cf7dc913da05f705f + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^5.69.0": + version: 5.69.0 + resolution: "@tanstack/react-query@npm:5.69.0" + dependencies: + "@tanstack/query-core": "npm:5.69.0" + peerDependencies: + react: ^18 || ^19 + checksum: 10c0/3481218d0dd1623af8148037011415ca6f57f2f918e859f6856b7d6cec191ca79029f6fd9e5b1139b1ec252198bda645ce8c1b44ac8dd28adf3c5f978a4126ec languageName: node linkType: hard @@ -15228,7 +15247,8 @@ __metadata: "@radix-ui/react-navigation-menu": "npm:^1.1.4" "@rainbow-me/rainbowkit": "npm:2" "@react-spring/web": "npm:^9.7.3" - "@tanstack/react-query": "npm:^5.20.1" + "@tanstack/react-query": "npm:^5.69.0" + "@tanstack/react-query-devtools": "npm:^5.69.0" "@testing-library/jest-dom": "npm:^6.1.5" "@testing-library/react": "npm:^14.1.2" "@testing-library/user-event": "npm:^14.5.2"