From 83e59b4519dad13543defe977a587330149e6bac Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 18 Nov 2025 18:13:25 -0300 Subject: [PATCH 1/4] feat: batch position snapshot fetching --- src/hooks/usePositionReport.ts | 125 ++++++++------- src/hooks/useUserPositions.ts | 142 +++++++--------- src/hooks/useUserPositionsSummaryData.ts | 4 +- src/utils/positions.ts | 196 +++++++++++++++-------- 4 files changed, 259 insertions(+), 208 deletions(-) diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 427091cc..2bcb6a14 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, PositionSnapshot } 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..8a791439 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -4,7 +4,7 @@ import { Address } from 'viem'; import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; import { SupportedNetworks } from '@/utils/networks'; import { - calculateEarningsFromPeriod as calculateEarnings, + calculateEarningsFromPeriod, initializePositionsWithEmptyEarnings, } from '@/utils/positions'; import { estimatedBlockNumber } from '@/utils/rpc'; @@ -163,7 +163,7 @@ const useUserPositionsSummaryData = ( const customRpcUrl = customRpcUrls[chainId] ?? undefined; - const earned = await calculateEarnings( + const earned = await calculateEarningsFromPeriod( position, history.items, user as Address, diff --git a/src/utils/positions.ts b/src/utils/positions.ts index e31516b8..b9186c12 100644 --- a/src/utils/positions.ts +++ b/src/utils/positions.ts @@ -70,102 +70,166 @@ 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> { + const result = new Map(); + + if (marketIds.length === 0) { + return result; + } + try { const isNow = blockNumber === 0; + const morphoAddress = getMorphoAddress(chainId as SupportedNetworks); - if (!isNow) { - console.log(`Get user position ${marketId.slice(0, 6)} at blockNumber ${blockNumber}`); - } else { - console.log(`Get user position ${marketId.slice(0, 6)} at current block`); - } + console.log(`Fetching ${marketIds.length} position snapshots at ${isNow ? 'current block' : `block ${blockNumber}`}`); - // First get the position data - const positionArray = (await client.readContract({ - address: getMorphoAddress(chainId as SupportedNetworks), + // Step 1: Multicall to get all position data + const positionContracts = marketIds.map((marketId) => ({ + address: morphoAddress as `0x${string}`, abi: morphoABI, - functionName: 'position', - args: [marketId as `0x${string}`, userAddress as Address], + functionName: 'position' as const, + args: [marketId as `0x${string}`, userAddress], + })); + + const positionResults = await client.multicall({ + contracts: positionContracts, + allowFailure: true, blockNumber: isNow ? undefined : BigInt(blockNumber), - })) as readonly bigint[]; + }); - // Convert array to position object - const position = arrayToPosition(positionArray); - - // If position has no shares, return zeros early - if ( - position.supplyShares === 0n && - position.borrowShares === 0n && - position.collateral === 0n - ) { - return { - supplyShares: '0', - supplyAssets: '0', - borrowShares: '0', - borrowAssets: '0', - collateral: '0', - }; - } + // Process position results and identify which markets need market data + const positions = new Map(); + const marketsNeedingData: string[] = []; + + positionResults.forEach((posResult, index) => { + const marketId = marketIds[index]; + if (posResult.status === 'success' && posResult.result) { + const position = arrayToPosition(posResult.result as readonly bigint[]); + positions.set(marketId, position); + + // Check if this position has any shares/collateral + if ( + position.supplyShares !== 0n || + position.borrowShares !== 0n || + position.collateral !== 0n + ) { + marketsNeedingData.push(marketId); + } else { + // No shares, set zero snapshot immediately + result.set(marketId, { + supplyShares: '0', + supplyAssets: '0', + borrowShares: '0', + borrowAssets: '0', + collateral: '0', + }); + } + } else { + console.warn(`Failed to fetch position for market ${marketId}`); + } + }); - // Only fetch market data if position has shares - const marketArray = (await client.readContract({ - address: getMorphoAddress(chainId as SupportedNetworks), - abi: morphoABI, - functionName: 'market', - args: [marketId as `0x${string}`], - blockNumber: isNow ? undefined : BigInt(blockNumber), - })) as readonly bigint[]; + // Step 2: Multicall to get market data for positions with shares + if (marketsNeedingData.length > 0) { + const marketContracts = marketsNeedingData.map((marketId) => ({ + address: morphoAddress as `0x${string}`, + abi: morphoABI, + functionName: 'market' as const, + args: [marketId as `0x${string}`], + })); + + const marketResults = await client.multicall({ + contracts: marketContracts, + allowFailure: true, + blockNumber: isNow ? undefined : BigInt(blockNumber), + }); - // Convert array to market object - const market = arrayToMarket(marketArray); + // Process market results and create final snapshots + marketResults.forEach((marketResult, index) => { + const marketId = marketsNeedingData[index]; + const position = positions.get(marketId); - // Convert shares to assets - const supplyAssets = convertSharesToAssets( - position.supplyShares, - market.totalSupplyAssets, - market.totalSupplyShares, - ); + if (!position) return; - const borrowAssets = convertSharesToAssets( - position.borrowShares, - market.totalBorrowAssets, - market.totalBorrowShares, - ); + if (marketResult.status === 'success' && marketResult.result) { + const market = arrayToMarket(marketResult.result as readonly bigint[]); - return { - supplyShares: position.supplyShares.toString(), - supplyAssets: supplyAssets.toString(), - borrowShares: position.borrowShares.toString(), - borrowAssets: borrowAssets.toString(), - collateral: position.collateral.toString(), - }; + const supplyAssets = convertSharesToAssets( + position.supplyShares, + market.totalSupplyAssets, + market.totalSupplyShares, + ); + + const borrowAssets = convertSharesToAssets( + position.borrowShares, + market.totalBorrowAssets, + market.totalBorrowShares, + ); + + result.set(marketId, { + supplyShares: position.supplyShares.toString(), + supplyAssets: supplyAssets.toString(), + borrowShares: position.borrowShares.toString(), + borrowAssets: borrowAssets.toString(), + collateral: position.collateral.toString(), + }); + } else { + console.warn(`Failed to fetch market data for ${marketId}`); + } + }); + } + + console.log(`Completed fetching ${result.size} position snapshots`); + return result; } catch (error) { - console.error(`Error reading position:`, { - marketId, + console.error(`Error fetching position snapshots:`, { + marketIds, userAddress, blockNumber, chainId, error, }); - return null; + return result; } } +/** + * Fetches a position snapshot for a specific market, user, and block number using a PublicClient + * + * @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) + * @param client - The viem PublicClient to use for the request + * @returns The position snapshot or null if there was an error + */ +export async function fetchPositionSnapshot( + marketId: string, + userAddress: Address, + chainId: number, + blockNumber: number, + client: PublicClient, +): Promise { + const snapshots = await fetchPositionsSnapshots([marketId], userAddress, chainId, blockNumber, client); + return snapshots.get(marketId) ?? null; +} + /** * Fetches a market snapshot for a specific market and block number using a PublicClient * From 1455cf3dc3244c0ae0c2f3e305d1f3c176f2f8b9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Tue, 18 Nov 2025 23:51:57 -0300 Subject: [PATCH 2/4] refactor: earnings --- app/positions/components/PositionsContent.tsx | 20 +- .../components/PositionsSummaryTable.tsx | 18 +- src/hooks/usePositionReport.ts | 2 +- src/hooks/useUserPositionsSummaryData.ts | 335 +++++++++--------- src/hooks/useVaultPage.ts | 7 +- src/utils/markets.ts | 1 + src/utils/positions.ts | 162 +-------- src/utils/types.ts | 2 +- 8 files changed, 221 insertions(+), 326 deletions(-) diff --git a/app/positions/components/PositionsContent.tsx b/app/positions/components/PositionsContent.tsx index 71d45b41..b8cc3ccf 100644 --- a/app/positions/components/PositionsContent.tsx +++ b/app/positions/components/PositionsContent.tsx @@ -16,7 +16,7 @@ import EmptyScreen from '@/components/Status/EmptyScreen'; import LoadingScreen from '@/components/Status/LoadingScreen'; import { SupplyModalV2 } from '@/components/SupplyModalV2'; import { useMarkets } from '@/hooks/useMarkets'; -import useUserPositionsSummaryData from '@/hooks/useUserPositionsSummaryData'; +import useUserPositionsSummaryData, { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { MarketPosition } from '@/utils/types'; import { OnboardingModal } from './onboarding/OnboardingModal'; import { PositionsSummaryTable } from './PositionsSummaryTable'; @@ -26,6 +26,7 @@ export default function Positions() { const [showWithdrawModal, setShowWithdrawModal] = useState(false); const [showOnboardingModal, setShowOnboardingModal] = useState(false); const [selectedPosition, setSelectedPosition] = useState(null); + const [earningsPeriod, setEarningsPeriod] = useState('day'); const { account } = useParams<{ account: string }>(); const { address } = useAccount(); @@ -49,10 +50,21 @@ export default function Positions() { isRefetching, positions: marketPositions, refetch, - } = useUserPositionsSummaryData(account); + loadingStates, + } = useUserPositionsSummaryData(account, earningsPeriod); const loading = isMarketsLoading || isPositionsLoading; + // Generate loading message based on current state + const loadingMessage = useMemo(() => { + if (isMarketsLoading) return 'Loading markets...'; + if (loadingStates.positions) return 'Loading user positions...'; + if (loadingStates.blocks) return 'Fetching block numbers...'; + if (loadingStates.snapshots) return 'Loading historical snapshots...'; + if (loadingStates.transactions) return 'Loading transaction history...'; + return 'Loading...'; + }, [isMarketsLoading, loadingStates]); + const hasSuppliedMarkets = marketPositions && marketPositions.length > 0; const handleRefetch = () => { @@ -122,7 +134,7 @@ export default function Positions() { /> {loading ? ( - + ) : !hasSuppliedMarkets ? (
@@ -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..25f86cf0 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 ( diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 2bcb6a14..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 { fetchPositionsSnapshots, PositionSnapshot } 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'; diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 8a791439..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, - 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 calculateEarningsFromPeriod( - 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 b9186c12..601dad0e 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'; @@ -97,8 +93,6 @@ export async function fetchPositionsSnapshots( const isNow = blockNumber === 0; const morphoAddress = getMorphoAddress(chainId as SupportedNetworks); - console.log(`Fetching ${marketIds.length} position snapshots at ${isNow ? 'current block' : `block ${blockNumber}`}`); - // Step 1: Multicall to get all position data const positionContracts = marketIds.map((marketId) => ({ address: morphoAddress as `0x${string}`, @@ -195,7 +189,6 @@ export async function fetchPositionsSnapshots( }); } - console.log(`Completed fetching ${result.size} position snapshots`); return result; } catch (error) { console.error(`Error fetching position snapshots:`, { @@ -286,149 +279,25 @@ export async function fetchMarketSnapshot( } } -/** - * 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 - * @param customRpcUrl - The custom RPC URL to use for the request - * @returns Position earnings data - */ -export async function calculateEarningsFromPeriod( - position: MarketPosition, - transactions: UserTransaction[], - userAddress: Address, - chainId: SupportedNetworks, - blockNumbers: { day?: number; week?: number; month?: number }, - customRpcUrl?: string, -): Promise { - if (!blockNumbers) { - return { - lifetimeEarned: '0', - last24hEarned: null, - last7dEarned: null, - last30dEarned: null, - }; - } - - 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 client = getClient(chainId, customRpcUrl); - - // Only fetch snapshots for requested periods - const snapshotPromises: (Promise | null)[] = [ - blockNumbers.day ? fetchPositionSnapshot(marketId, userAddress, chainId, blockNumbers.day, client) : null, - blockNumbers.week ? fetchPositionSnapshot(marketId, userAddress, chainId, blockNumbers.week, client) : null, - blockNumbers.month ? fetchPositionSnapshot(marketId, userAddress, chainId, blockNumbers.month, client) : null, - ]; - - const snapshots = await Promise.all(snapshotPromises.map(async p => p ?? Promise.resolve(null))); - - const [snapshot24h, snapshot7d, snapshot30d] = snapshots; - - const lifetimeEarnings = calculateEarningsFromSnapshot(currentBalance, 0n, marketTxs, 0, now); - const last24hEarnings = snapshot24h && blockNumbers.day - ? calculateEarningsFromSnapshot( - currentBalance, - BigInt(snapshot24h.supplyAssets), - marketTxs, - now - 24 * 60 * 60, - now, - ) - : null; - const last7dEarnings = snapshot7d && blockNumbers.week - ? calculateEarningsFromSnapshot( - currentBalance, - BigInt(snapshot7d.supplyAssets), - marketTxs, - now - 7 * 24 * 60 * 60, - now, - ) - : null; - const last30dEarnings = snapshot30d && blockNumbers.month - ? 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 - ); +export function getGroupedEarnings(groupedPosition: GroupedPosition): string | null { + let total = 0n; + + for (const position of groupedPosition.markets) { + const earnings = position.earned; + if (earnings) { + total += BigInt(earnings); + } + } + + return total.toString(); } /** @@ -471,7 +340,7 @@ export function groupPositionsByLoanAsset( // Check if position should be included in the group const shouldInclude = BigInt(position.state.supplyShares) > 0 || - getEarningsForPeriod(position, EarningsPeriod.All) !== '0'; + position.earned !== '0' if (shouldInclude) { groupedPosition.markets.push(position); @@ -565,11 +434,6 @@ export function initializePositionsWithEmptyEarnings( ): MarketPositionWithEarnings[] { return positions.map((position) => ({ ...position, - earned: { - lifetimeEarned: '0', - last24hEarned: null, - last7dEarned: null, - last30dEarned: null, - }, + earned: '0', })); } diff --git a/src/utils/types.ts b/src/utils/types.ts index c5d0f051..ab8bfa97 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -21,7 +21,7 @@ export type MarketPosition = { }; export type MarketPositionWithEarnings = MarketPosition & { - earned: PositionEarnings; + earned: string; }; export enum UserTxTypes { From 96e2ff22fa1815ffce2ab7b8f02bd4a284b004bd Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 19 Nov 2025 00:01:38 -0300 Subject: [PATCH 3/4] misc: homepage layout --- app/HomePage.tsx | 111 ++++++++++++------------ src/components/Status/LoadingScreen.tsx | 85 +++++++++++++++++- src/components/layout/header/Header.tsx | 4 +- 3 files changed, 141 insertions(+), 59 deletions(-) diff --git a/app/HomePage.tsx b/app/HomePage.tsx index 0d345de2..2f1c9e52 100644 --- a/app/HomePage.tsx +++ b/app/HomePage.tsx @@ -160,7 +160,7 @@ function CustomTypingAnimation() { }; return ( -
+
{renderColoredText()}
-
+
{/* Logo and Product Title - Horizontal Layout */} -
-

+
+

Welcome to Monarch

Monarch Logo
{/* Tagline with typing animation */} -
-
+
+
{/* CTA Buttons */} -
+
- - @@ -255,34 +255,34 @@ function HomePage() { {/* Section 1: Introducing Monarch - Full Screen, Left Layout with Image */}
-
-
+
+
{/* Text Content */}
-

+

Introducing Monarch

-

+

Advanced Interface for Morpho Blue

-
-

+

+

Morpho Blue is the core protocol of the Morpho ecosystem—a decentralized, immutable, and neutral lending protocol that enables the creation of lending markets with any assets.

-

+

Monarch is an advanced interface for Morpho Blue, providing powerful tools to interact directly with the protocol—from simple lending to creating your own automated vaults.

-
{/* Section 2: Morpho Vaults - Full Screen, Right Layout with Image */} -
-
-
+
+
{/* Section 3: Direct Market Access - Full Screen, Left Layout with Animation */} -
-
+
+
{/* Text Content - Centered */} -
-

+
+

Why Monarch?

-

+

Advanced Tools for DeFi Power Users

-
-

+

+

Monarch provides direct access to Morpho Blue markets with no intermediaries and zero fees. Our powerful tools are designed for sophisticated users who want maximum control and capital efficiency:

-
    -
  • +
      +
    • Market Discovery: Find the best lending opportunities with the highest APY while understanding the risk trade-offs.
    • -
    • +
    • Risk Analysis: Comprehensive risk metrics and analytics on every market, helping you make informed decisions.
    • -
    • +
    • Smart Rebalancing: Tools designed to help you identify optimal yield opportunities and easily rebalance your positions across multiple markets.
    • @@ -404,22 +405,22 @@ function HomePage() {
{/* Rebalance Animation */} -
+
{/* CTA Buttons - Centered */} -
+
- - @@ -495,11 +496,11 @@ function HomePage() { {/* Footer CTA - Full Screen */}
-
-

+
+

Join the Monarch Community

-

+

Connect with us and stay updated on the latest features and developments in decentralized lending.

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 (
Logo -

{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 */}
From ff768f263dca886861b663b66f5583991dc2e91f Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 19 Nov 2025 09:08:21 -0300 Subject: [PATCH 4/4] chore: lint --- app/positions/components/PositionsSummaryTable.tsx | 2 +- src/utils/positions.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/positions/components/PositionsSummaryTable.tsx b/app/positions/components/PositionsSummaryTable.tsx index 25f86cf0..972168c7 100644 --- a/app/positions/components/PositionsSummaryTable.tsx +++ b/app/positions/components/PositionsSummaryTable.tsx @@ -378,7 +378,7 @@ export function PositionsSummaryTable({ ) : ( {(() => { - if (earnings === null) return '-'; + if (earnings === 0n) return '-'; return ( formatReadable( Number( diff --git a/src/utils/positions.ts b/src/utils/positions.ts index 601dad0e..d5f60b7b 100644 --- a/src/utils/positions.ts +++ b/src/utils/positions.ts @@ -285,9 +285,9 @@ export async function fetchMarketSnapshot( * Get combined earnings for a group of positions * * @param groupedPosition - The grouped position - * @returns The total earnings as a string or null + * @returns The total earnings as a string */ -export function getGroupedEarnings(groupedPosition: GroupedPosition): string | null { +export function getGroupedEarnings(groupedPosition: GroupedPosition): bigint { let total = 0n; for (const position of groupedPosition.markets) { @@ -297,7 +297,7 @@ export function getGroupedEarnings(groupedPosition: GroupedPosition): string | n } } - return total.toString(); + return total; } /**