From 66003c15e4256a661dd70c47716970a9c27e80a9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 21 Mar 2025 14:02:26 +0800 Subject: [PATCH 1/2] refactor: position page initial loading --- app/positions/components/PositionsContent.tsx | 6 +- .../components/PositionsSummaryTable.tsx | 203 ++-------- src/hooks/usePositionReport.ts | 3 +- src/hooks/usePositionSnapshot.ts | 71 ---- src/hooks/useUserPosition.ts | 4 +- src/hooks/useUserPositions.ts | 3 +- src/hooks/useUserPositionsSummaryData.ts | 159 +++----- src/utils/positions.ts | 368 ++++++++++++++++++ 8 files changed, 466 insertions(+), 351 deletions(-) delete mode 100644 src/hooks/usePositionSnapshot.ts create mode 100644 src/utils/positions.ts diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index 86a66abf..f9236c46 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -43,7 +43,8 @@ export default function Positions() { }, [account, address]); const { - isLoading, + isPositionsLoading, + isEarningsLoading, isRefetching, positions: marketPositions, refetch, @@ -143,7 +144,7 @@ export default function Positions() { userRebalancerInfo={rebalancerInfo} /> - {isLoading ? ( + {isPositionsLoading ? ( ) : !hasSuppliedMarkets ? (
@@ -172,6 +173,7 @@ export default function Positions() { setSelectedPosition={setSelectedPosition} refetch={refetch} isRefetching={isRefetching} + isLoadingEarnings={isEarningsLoading} rebalancerInfo={rebalancerInfo} />
diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 5739d15e..b97f0c27 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -6,6 +6,7 @@ import Image from 'next/image'; import { BsQuestionCircle } from 'react-icons/bs'; import { IoRefreshOutline, IoChevronDownOutline } from 'react-icons/io5'; import { PiHandCoins } from 'react-icons/pi'; +import { PulseLoader} from 'react-spinners'; import { useAccount } from 'wagmi'; import { Button } from '@/components/common/Button'; import { TokenIcon } from '@/components/TokenIcon'; @@ -13,10 +14,15 @@ import { TooltipContent } from '@/components/TooltipContent'; import { useStyledToast } from '@/hooks/useStyledToast'; import { formatReadable, formatBalance } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; +import { + EarningsPeriod, + getGroupedEarnings, + groupPositionsByLoanAsset, + processCollaterals +} from '@/utils/positions'; import { MarketPosition, GroupedPosition, - WarningWithDetail, MarketPositionWithEarnings, UserRebalancerInfo, } from '@/utils/types'; @@ -28,13 +34,6 @@ import { import { RebalanceModal } from './RebalanceModal'; import { SuppliedMarketsDetail } from './SuppliedMarketsDetail'; -export enum EarningsPeriod { - All = 'all', - Day = '1D', - Week = '7D', - Month = '30D', -} - type PositionsSummaryTableProps = { account: string; marketPositions: MarketPositionWithEarnings[]; @@ -43,6 +42,7 @@ type PositionsSummaryTableProps = { setSelectedPosition: (position: MarketPosition) => void; refetch: (onSuccess?: () => void) => void; isRefetching: boolean; + isLoadingEarnings?: boolean; rebalancerInfo: UserRebalancerInfo | undefined; }; @@ -53,6 +53,7 @@ export function PositionsSummaryTable({ setSelectedPosition, refetch, isRefetching, + isLoadingEarnings, account, rebalancerInfo, }: PositionsSummaryTableProps) { @@ -72,45 +73,6 @@ export function PositionsSummaryTable({ return account === address; }, [account, address]); - const getEarningsForPeriod = (position: MarketPositionWithEarnings) => { - if (!position.earned) return '0'; - - switch (earningsPeriod) { - case EarningsPeriod.All: - return position.earned.lifetimeEarned; - case EarningsPeriod.Day: - return position.earned.last24hEarned; - case EarningsPeriod.Week: - return position.earned.last7dEarned; - case EarningsPeriod.Month: - return position.earned.last30dEarned; - default: - return '0'; - } - }; - - const getGroupedEarnings = (groupedPosition: GroupedPosition) => { - console.log('gruping earnings from', groupedPosition.markets.length, 'positions'); - - for (const position of groupedPosition.markets) { - const earnings = getEarningsForPeriod(position); - console.log('position', position.market.uniqueKey, 'earnings', earnings); - } - - return ( - groupedPosition.markets - .reduce( - (total, position) => { - const earnings = getEarningsForPeriod(position); - if (earnings === null) return null; - return total === null ? BigInt(earnings) : total + BigInt(earnings); - }, - null as bigint | null, - ) - ?.toString() ?? null - ); - }; - const periodLabels: Record = { [EarningsPeriod.All]: 'All Time', [EarningsPeriod.Day]: '1D', @@ -118,116 +80,13 @@ export function PositionsSummaryTable({ [EarningsPeriod.Month]: '30D', }; - const groupedPositions: GroupedPosition[] = useMemo(() => { - return marketPositions - .filter( - (position) => - BigInt(position.state.supplyShares) > 0 || - rebalancerInfo?.marketCaps.some((c) => c.marketId === position.market.uniqueKey), - ) - .reduce((acc: GroupedPosition[], position) => { - const loanAssetAddress = position.market.loanAsset.address; - const loanAssetDecimals = position.market.loanAsset.decimals; - const chainId = position.market.morphoBlue.chain.id; - - let groupedPosition = acc.find( - (gp) => gp.loanAssetAddress === loanAssetAddress && gp.chainId === chainId, - ); - - if (!groupedPosition) { - groupedPosition = { - loanAsset: position.market.loanAsset.symbol || 'Unknown', - loanAssetAddress, - loanAssetDecimals, - chainId, - totalSupply: 0, - totalWeightedApy: 0, - collaterals: [], - markets: [], - processedCollaterals: [], - allWarnings: [], - }; - acc.push(groupedPosition); - } - - // only push if the position has > 0 supply, earning or is in rebalancer info - if ( - Number(position.state.supplyShares) === 0 && - !rebalancerInfo?.marketCaps.some((c) => c.marketId === position.market.uniqueKey) - ) { - return acc; - } - - groupedPosition.markets.push(position); - - groupedPosition.allWarnings = [ - ...new Set([ - ...groupedPosition.allWarnings, - ...(position.market.warningsWithDetail || []), - ]), - ] as WarningWithDetail[]; + const groupedPositions = useMemo(() => + groupPositionsByLoanAsset(marketPositions, rebalancerInfo), + [marketPositions, rebalancerInfo]); - const supplyAmount = Number( - formatBalance(position.state.supplyAssets, position.market.loanAsset.decimals), - ); - groupedPosition.totalSupply += supplyAmount; - - const weightedApy = supplyAmount * position.market.state.supplyApy; - groupedPosition.totalWeightedApy += weightedApy; - - const collateralAddress = position.market.collateralAsset?.address; - const collateralSymbol = position.market.collateralAsset?.symbol; - - if (collateralAddress && collateralSymbol) { - const existingCollateral = groupedPosition.collaterals.find( - (c) => c.address === collateralAddress, - ); - if (existingCollateral) { - existingCollateral.amount += supplyAmount; - } else { - groupedPosition.collaterals.push({ - address: collateralAddress, - symbol: collateralSymbol, - amount: supplyAmount, - }); - } - } - - return acc; - }, []) - .filter((groupedPosition) => groupedPosition.totalSupply > 0) - .sort((a, b) => b.totalSupply - a.totalSupply); - }, [marketPositions, rebalancerInfo]); - - const processedPositions = useMemo(() => { - return groupedPositions.map((position) => { - const sortedCollaterals = [...position.collaterals].sort((a, b) => b.amount - a.amount); - const totalSupply = position.totalSupply; - const processedCollaterals = []; - let othersAmount = 0; - - for (const collateral of sortedCollaterals) { - const percentage = (collateral.amount / totalSupply) * 100; - if (percentage >= 5) { - processedCollaterals.push({ ...collateral, percentage }); - } else { - othersAmount += collateral.amount; - } - } - - if (othersAmount > 0) { - const othersPercentage = (othersAmount / totalSupply) * 100; - processedCollaterals.push({ - address: 'others', - symbol: 'Others', - amount: othersAmount, - percentage: othersPercentage, - }); - } - - return { ...position, processedCollaterals }; - }); - }, [groupedPositions]); + const processedPositions = useMemo(() => + processCollaterals(groupedPositions), + [groupedPositions]); useEffect(() => { if (selectedGroupedPosition) { @@ -330,7 +189,7 @@ export function PositionsSummaryTable({ const isExpanded = expandedRows.has(rowKey); const avgApy = groupedPosition.totalWeightedApy / groupedPosition.totalSupply; - const earnings = getGroupedEarnings(groupedPosition); + const earnings = getGroupedEarnings(groupedPosition, earningsPeriod); return ( @@ -369,18 +228,24 @@ export function PositionsSummaryTable({
- - {(() => { - if (earnings === null) return '-'; - return ( - formatReadable( - Number(formatBalance(earnings, groupedPosition.loanAssetDecimals)), - ) + - ' ' + - groupedPosition.loanAsset - ); - })()} - + {isLoadingEarnings ? ( +
+ +
+ ) : ( + + {(() => { + if (earnings === null) return '-'; + return ( + formatReadable( + Number(formatBalance(earnings, groupedPosition.loanAssetDecimals)), + ) + + ' ' + + groupedPosition.loanAsset + ); + })()} + + )}
diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 2d7c1520..52400872 100644 --- a/src/hooks/usePositionReport.ts +++ b/src/hooks/usePositionReport.ts @@ -4,9 +4,9 @@ import { EarningsCalculation, filterTransactionsInPeriod, } from '@/utils/interest'; +import { fetchPositionSnapshot } from '@/utils/positions'; import { estimatedBlockNumber } from '@/utils/rpc'; import { Market, MarketPosition, UserTransaction } from '@/utils/types'; -import { usePositionSnapshot } from './usePositionSnapshot'; import useUserTransactions from './useUserTransactions'; export type PositionReport = { @@ -38,7 +38,6 @@ export const usePositionReport = ( startDate?: Date, endDate?: Date, ) => { - const { fetchPositionSnapshot } = usePositionSnapshot(); const { fetchTransactions } = useUserTransactions(); const generateReport = async (): Promise => { diff --git a/src/hooks/usePositionSnapshot.ts b/src/hooks/usePositionSnapshot.ts deleted file mode 100644 index 5b8949b5..00000000 --- a/src/hooks/usePositionSnapshot.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback } from 'react'; -import { Address } from 'viem'; - -export type PositionSnapshot = { - supplyAssets: string; - supplyShares: string; - borrowAssets: string; - borrowShares: string; - collateral: string; -}; - -type PositionResponse = { - position: { - supplyAssets: string; - supplyShares: string; - borrowAssets: string; - borrowShares: string; - collateral: string; - } | null; -}; - -export function usePositionSnapshot() { - const fetchPositionSnapshot = useCallback( - async ( - marketId: string, - userAddress: Address, - chainId: number, - blockNumber: number, - ): Promise => { - try { - // Then, fetch the position at that block number - const positionResponse = await fetch( - `/api/positions/historical?` + - `marketId=${encodeURIComponent(marketId)}` + - `&userAddress=${encodeURIComponent(userAddress)}` + - `&blockNumber=${encodeURIComponent(blockNumber)}` + - `&chainId=${encodeURIComponent(chainId)}`, - ); - - if (!positionResponse.ok) { - const errorData = (await positionResponse.json()) as { error?: string }; - console.error('Failed to fetch position snapshot:', errorData); - return null; - } - - const positionData = (await positionResponse.json()) as PositionResponse; - - // If position is empty, return zeros - if (!positionData.position) { - return { - supplyAssets: '0', - supplyShares: '0', - borrowAssets: '0', - borrowShares: '0', - collateral: '0', - }; - } - - return { - ...positionData.position, - }; - } catch (error) { - console.error('Error fetching position snapshot:', error); - return null; - } - }, - [], - ); - - return { fetchPositionSnapshot }; -} diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index f4e4e69e..0356a3ff 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -2,9 +2,9 @@ import { useState, useEffect, useCallback } from 'react'; import { Address } from 'viem'; import { userPositionForMarketQuery } from '@/graphql/queries'; import { SupportedNetworks } from '@/utils/networks'; +import { fetchPositionSnapshot } from '@/utils/positions'; import { MarketPosition } from '@/utils/types'; import { URLS } from '@/utils/urls'; -import { usePositionSnapshot } from './usePositionSnapshot'; const useUserPositions = ( user: string | undefined, @@ -16,8 +16,6 @@ const useUserPositions = ( const [position, setPosition] = useState(null); const [positionsError, setPositionsError] = useState(null); - const { fetchPositionSnapshot } = usePositionSnapshot(); - const fetchData = useCallback( async (isRefetch = false, onSuccess?: () => void) => { if (!user) { diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 79abeaae..ff269944 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -4,12 +4,12 @@ import { useState, useEffect, useCallback } from 'react'; 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 { URLS } from '@/utils/urls'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; import { useUserMarketsCache } from '../hooks/useUserMarketsCache'; import { useMarkets } from './useMarkets'; -import { usePositionSnapshot } from './usePositionSnapshot'; const useUserPositions = (user: string | undefined, showEmpty = false) => { const [loading, setLoading] = useState(true); @@ -19,7 +19,6 @@ const useUserPositions = (user: string | undefined, showEmpty = false) => { const { markets } = useMarkets(); - const { fetchPositionSnapshot } = usePositionSnapshot(); const { getUserMarkets, batchAddUserMarkets } = useUserMarketsCache(); const fetchData = useCallback( diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index b5aa6905..fc0dcb6b 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,15 +1,12 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { Address } from 'viem'; -import { calculateEarningsFromSnapshot } from '@/utils/interest'; import { SupportedNetworks } from '@/utils/networks'; +import { + calculateEarningsFromPeriod as calculateEarnings, + initializePositionsWithEmptyEarnings +} from '@/utils/positions'; import { estimatedBlockNumber } from '@/utils/rpc'; -import { - MarketPosition, - MarketPositionWithEarnings, - PositionEarnings, - UserTransaction, -} from '@/utils/types'; -import { usePositionSnapshot } from './usePositionSnapshot'; +import { MarketPositionWithEarnings } from '@/utils/types'; import useUserPositions from './useUserPositions'; import useUserTransactions from './useUserTransactions'; @@ -32,7 +29,6 @@ const useUserPositionsSummaryData = (user: string | undefined) => { refetch, } = useUserPositions(user, true); - const { fetchPositionSnapshot } = usePositionSnapshot(); const { fetchTransactions } = useUserTransactions(); const [positionsWithEarnings, setPositionsWithEarnings] = useState( @@ -43,8 +39,11 @@ const useUserPositionsSummaryData = (user: string | undefined) => { const [isLoadingEarnings, setIsLoadingEarnings] = useState(false); const [error, setError] = useState(null); - // Loading state that combines all loading states - const isLoading = positionsLoading || isLoadingBlockNums || isLoadingEarnings; + // Loading state for positions that doesn't include earnings calculation + const isPositionsLoading = positionsLoading; + + // Loading state that combines all loading states (used for earnings) + const isEarningsLoading = isLoadingBlockNums || isLoadingEarnings; useEffect(() => { const fetchBlockNums = async () => { @@ -94,75 +93,15 @@ const useUserPositionsSummaryData = (user: string | undefined) => { void fetchBlockNums(); }, []); - const calculateEarningsFromPeriod = useCallback( - async ( - position: MarketPosition, - transactions: UserTransaction[], - userAddress: Address, - chainId: SupportedNetworks, - ) => { - if (!blockNums?.[chainId]) { - return { - lifetimeEarned: '0', - last24hEarned: '0', - last7dEarned: '0', - last30dEarned: '0', - }; - } - - const currentBalance = BigInt(position.state.supplyAssets); - const marketId = position.market.uniqueKey; - const marketTxs = transactions.filter((tx) => tx.data?.market?.uniqueKey === marketId); - const now = Math.floor(Date.now() / 1000); - const blockNum = blockNums[chainId]; - - const snapshots = await Promise.all([ - fetchPositionSnapshot(marketId, userAddress, chainId, blockNum.day), - fetchPositionSnapshot(marketId, userAddress, chainId, blockNum.week), - fetchPositionSnapshot(marketId, userAddress, chainId, blockNum.month), - ]); - - const [snapshot24h, snapshot7d, snapshot30d] = snapshots; - - const lifetimeEarnings = calculateEarningsFromSnapshot(currentBalance, 0n, marketTxs, 0, now); - const last24hEarnings = snapshot24h - ? calculateEarningsFromSnapshot( - currentBalance, - BigInt(snapshot24h.supplyAssets), - marketTxs, - now - 24 * 60 * 60, - now, - ) - : null; - const last7dEarnings = snapshot7d - ? calculateEarningsFromSnapshot( - currentBalance, - BigInt(snapshot7d.supplyAssets), - marketTxs, - now - 7 * 24 * 60 * 60, - now, - ) - : null; - const last30dEarnings = snapshot30d - ? calculateEarningsFromSnapshot( - currentBalance, - BigInt(snapshot30d.supplyAssets), - marketTxs, - now - 30 * 24 * 60 * 60, - now, - ) - : null; - - return { - lifetimeEarned: lifetimeEarnings.earned.toString(), - last24hEarned: last24hEarnings ? last24hEarnings.earned.toString() : null, - last7dEarned: last7dEarnings ? last7dEarnings.earned.toString() : null, - last30dEarned: last30dEarnings ? last30dEarnings.earned.toString() : null, - } as PositionEarnings; - }, - [fetchPositionSnapshot, blockNums], - ); + // Create positions with empty earnings as soon as positions are loaded + useEffect(() => { + if (positions && positions.length > 0) { + // Initialize positions with empty earnings data to display immediately + setPositionsWithEarnings(initializePositionsWithEmptyEarnings(positions)); + } + }, [positions]); + // Calculate real earnings in the background useEffect(() => { const updatePositionsWithEarnings = async () => { try { @@ -171,27 +110,42 @@ const useUserPositionsSummaryData = (user: string | undefined) => { setIsLoadingEarnings(true); setError(null); - const positionsWithEarningsData = await Promise.all( - positions.map(async (position) => { - const history = await fetchTransactions({ - userAddress: [user], - marketUniqueKeys: [position.market.uniqueKey], - }); - - const earned = await calculateEarningsFromPeriod( - position, - history.items, - user as Address, - position.market.morphoBlue.chain.id as SupportedNetworks, + // Process positions one by one to update earnings progressively + 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 ); - return { - ...position, - earned, - }; - }), - ); - - setPositionsWithEarnings(positionsWithEarningsData); + + if (positionIndex !== -1) { + updatedPositions[positionIndex] = { + ...updatedPositions[positionIndex], + earned, + }; + } + + return updatedPositions; + }); + } } catch (err) { setError(err instanceof Error ? err : new Error('Failed to calculate earnings')); } finally { @@ -200,11 +154,12 @@ const useUserPositionsSummaryData = (user: string | undefined) => { }; void updatePositionsWithEarnings(); - }, [positions, user, blockNums, calculateEarningsFromPeriod, fetchTransactions]); + }, [positions, user, blockNums, fetchTransactions]); return { positions: positionsWithEarnings, - isLoading, + isPositionsLoading, // For initial load of positions only + isEarningsLoading, // For earnings calculation isRefetching, error: error ?? positionsError, refetch, diff --git a/src/utils/positions.ts b/src/utils/positions.ts new file mode 100644 index 00000000..a8ef8385 --- /dev/null +++ b/src/utils/positions.ts @@ -0,0 +1,368 @@ +import { Address } from 'viem'; +import { formatBalance } from './balance'; +import { calculateEarningsFromSnapshot } from './interest'; +import { SupportedNetworks } from './networks'; +import { + MarketPosition, + MarketPositionWithEarnings, + PositionEarnings, + UserTransaction, + GroupedPosition, + WarningWithDetail +} from './types'; + +export type PositionSnapshot = { + supplyAssets: string; + supplyShares: string; + borrowAssets: string; + borrowShares: string; + collateral: string; +}; + +type PositionResponse = { + position: { + supplyAssets: string; + supplyShares: string; + borrowAssets: string; + borrowShares: string; + collateral: string; + } | null; +}; + +/** + * Fetches a position snapshot for a specific market, user, and block number + * + * @param marketId - The unique ID of the market + * @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) + * @returns The position snapshot or null if there was an error + */ +export async function fetchPositionSnapshot( + marketId: string, + userAddress: Address, + chainId: number, + blockNumber: number, +): Promise { + try { + // Fetch the position at the specified block number + const positionResponse = await fetch( + `/api/positions/historical?` + + `marketId=${encodeURIComponent(marketId)}` + + `&userAddress=${encodeURIComponent(userAddress)}` + + `&blockNumber=${encodeURIComponent(blockNumber)}` + + `&chainId=${encodeURIComponent(chainId)}`, + ); + + if (!positionResponse.ok) { + const errorData = (await positionResponse.json()) as { error?: string }; + console.error('Failed to fetch position snapshot:', errorData); + return null; + } + + const positionData = (await positionResponse.json()) as PositionResponse; + + // If position is empty, return zeros + if (!positionData.position) { + return { + supplyAssets: '0', + supplyShares: '0', + borrowAssets: '0', + borrowShares: '0', + collateral: '0', + }; + } + + return { + ...positionData.position, + }; + } catch (error) { + console.error('Error fetching position snapshot:', error); + return null; + } +} + +/** + * Calculates earnings for a position across different time periods + * + * @param position - The market position + * @param transactions - User transactions for the position + * @param userAddress - The user's address + * @param chainId - The chain ID + * @param blockNumbers - Block numbers for different time periods + * @returns Position earnings data + */ +export async function calculateEarningsFromPeriod( + position: MarketPosition, + transactions: UserTransaction[], + userAddress: Address, + chainId: SupportedNetworks, + blockNumbers: { day: number; week: number; month: number }, +): Promise { + if (!blockNumbers) { + return { + lifetimeEarned: '0', + last24hEarned: '0', + last7dEarned: '0', + last30dEarned: '0', + }; + } + + const currentBalance = BigInt(position.state.supplyAssets); + const marketId = position.market.uniqueKey; + const marketTxs = transactions.filter((tx) => tx.data?.market?.uniqueKey === marketId); + const now = Math.floor(Date.now() / 1000); + + const snapshots = await Promise.all([ + fetchPositionSnapshot(marketId, userAddress, chainId, blockNumbers.day), + fetchPositionSnapshot(marketId, userAddress, chainId, blockNumbers.week), + fetchPositionSnapshot(marketId, userAddress, chainId, blockNumbers.month), + ]); + + const [snapshot24h, snapshot7d, snapshot30d] = snapshots; + + const lifetimeEarnings = calculateEarningsFromSnapshot(currentBalance, 0n, marketTxs, 0, now); + const last24hEarnings = snapshot24h + ? calculateEarningsFromSnapshot( + currentBalance, + BigInt(snapshot24h.supplyAssets), + marketTxs, + now - 24 * 60 * 60, + now, + ) + : null; + const last7dEarnings = snapshot7d + ? calculateEarningsFromSnapshot( + currentBalance, + BigInt(snapshot7d.supplyAssets), + marketTxs, + now - 7 * 24 * 60 * 60, + now, + ) + : null; + const last30dEarnings = snapshot30d + ? calculateEarningsFromSnapshot( + currentBalance, + BigInt(snapshot30d.supplyAssets), + marketTxs, + now - 30 * 24 * 60 * 60, + now, + ) + : null; + + return { + lifetimeEarned: lifetimeEarnings.earned.toString(), + last24hEarned: last24hEarnings ? last24hEarnings.earned.toString() : null, + last7dEarned: last7dEarnings ? last7dEarnings.earned.toString() : null, + last30dEarned: last30dEarnings ? last30dEarnings.earned.toString() : null, + }; +} + +/** + * Export enum for earnings period selection + */ +export enum EarningsPeriod { + All = 'all', + Day = '1D', + Week = '7D', + Month = '30D', +} + +/** + * Get the earnings value for a specific period + * + * @param position - Position with earnings data + * @param period - The period to get earnings for + * @returns The earnings value as a string + */ +export function getEarningsForPeriod( + position: MarketPositionWithEarnings, + period: EarningsPeriod +): string | null { + if (!position.earned) return '0'; + + switch (period) { + case EarningsPeriod.All: + return position.earned.lifetimeEarned; + case EarningsPeriod.Day: + return position.earned.last24hEarned; + case EarningsPeriod.Week: + return position.earned.last7dEarned; + case EarningsPeriod.Month: + return position.earned.last30dEarned; + default: + return '0'; + } +} + +/** + * Get combined earnings for a group of positions + * + * @param groupedPosition - The grouped position + * @param period - The period to get earnings for + * @returns The total earnings as a string or null + */ +export function getGroupedEarnings( + groupedPosition: GroupedPosition, + period: EarningsPeriod +): string | null { + return ( + groupedPosition.markets + .reduce( + (total, position) => { + const earnings = getEarningsForPeriod(position, period); + if (earnings === null) return null; + return total === null ? BigInt(earnings) : total + BigInt(earnings); + }, + null as bigint | null, + ) + ?.toString() ?? null + ); +} + +/** + * Group positions by loan asset + * + * @param positions - Array of positions with earnings + * @param rebalancerInfo - Optional rebalancer info + * @returns Array of grouped positions + */ +export function groupPositionsByLoanAsset( + positions: MarketPositionWithEarnings[], + rebalancerInfo?: { marketCaps: { marketId: string }[] } +): GroupedPosition[] { + return positions + .filter( + (position) => + BigInt(position.state.supplyShares) > 0 || + rebalancerInfo?.marketCaps.some((c) => c.marketId === position.market.uniqueKey), + ) + .reduce((acc: GroupedPosition[], position) => { + const loanAssetAddress = position.market.loanAsset.address; + const loanAssetDecimals = position.market.loanAsset.decimals; + const chainId = position.market.morphoBlue.chain.id; + + let groupedPosition = acc.find( + (gp) => gp.loanAssetAddress === loanAssetAddress && gp.chainId === chainId, + ); + + if (!groupedPosition) { + groupedPosition = { + loanAsset: position.market.loanAsset.symbol || 'Unknown', + loanAssetAddress, + loanAssetDecimals, + chainId, + totalSupply: 0, + totalWeightedApy: 0, + collaterals: [], + markets: [], + processedCollaterals: [], + allWarnings: [], + }; + acc.push(groupedPosition); + } + + // only push if the position has > 0 supply, earning or is in rebalancer info + if ( + Number(position.state.supplyShares) === 0 && + !rebalancerInfo?.marketCaps.some((c) => c.marketId === position.market.uniqueKey) + ) { + return acc; + } + + groupedPosition.markets.push(position); + + groupedPosition.allWarnings = [ + ...new Set([ + ...groupedPosition.allWarnings, + ...(position.market.warningsWithDetail || []), + ]), + ] as WarningWithDetail[]; + + const supplyAmount = Number( + formatBalance(position.state.supplyAssets, position.market.loanAsset.decimals), + ); + groupedPosition.totalSupply += supplyAmount; + + const weightedApy = supplyAmount * position.market.state.supplyApy; + groupedPosition.totalWeightedApy += weightedApy; + + const collateralAddress = position.market.collateralAsset?.address; + const collateralSymbol = position.market.collateralAsset?.symbol; + + if (collateralAddress && collateralSymbol) { + const existingCollateral = groupedPosition.collaterals.find( + (c) => c.address === collateralAddress, + ); + if (existingCollateral) { + existingCollateral.amount += supplyAmount; + } else { + groupedPosition.collaterals.push({ + address: collateralAddress, + symbol: collateralSymbol, + amount: supplyAmount, + }); + } + } + + return acc; + }, []) + .filter((groupedPosition) => groupedPosition.totalSupply > 0) + .sort((a, b) => b.totalSupply - a.totalSupply); +} + +/** + * Process collaterals for grouped positions, simplifying small collaterals into an "Others" category + * + * @param groupedPositions - Array of grouped positions + * @returns Processed grouped positions with simplified collaterals + */ +export function processCollaterals(groupedPositions: GroupedPosition[]): GroupedPosition[] { + return groupedPositions.map((position) => { + const sortedCollaterals = [...position.collaterals].sort((a, b) => b.amount - a.amount); + const totalSupply = position.totalSupply; + const processedCollaterals = []; + let othersAmount = 0; + + for (const collateral of sortedCollaterals) { + const percentage = (collateral.amount / totalSupply) * 100; + if (percentage >= 5) { + processedCollaterals.push({ ...collateral, percentage }); + } else { + othersAmount += collateral.amount; + } + } + + if (othersAmount > 0) { + const othersPercentage = (othersAmount / totalSupply) * 100; + processedCollaterals.push({ + address: 'others', + symbol: 'Others', + amount: othersAmount, + percentage: othersPercentage, + }); + } + + return { ...position, processedCollaterals }; + }); +} + +/** + * Initialize positions with empty earnings data + * + * @param positions - Original positions without earnings data + * @returns Positions with initialized empty earnings + */ +export function initializePositionsWithEmptyEarnings( + positions: MarketPosition[] +): MarketPositionWithEarnings[] { + return positions.map(position => ({ + ...position, + earned: { + lifetimeEarned: '0', + last24hEarned: null, + last7dEarned: null, + last30dEarned: null, + } + })); +} \ No newline at end of file From 45b895ad256c4827419b92f02e188980e6ce3206 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 21 Mar 2025 14:13:41 +0800 Subject: [PATCH 2/2] chore: add comment --- src/hooks/useUserPositionsSummaryData.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index fc0dcb6b..264dba01 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -111,6 +111,7 @@ const useUserPositionsSummaryData = (user: string | undefined) => { 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],