From 8b8076810524428430e54efed36d11c5081d5caf Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 15:47:33 +0800 Subject: [PATCH 01/11] refactor: earning calculation --- app/api/block/route.ts | 77 ----- .../positions-report-view.tsx | 4 +- .../supplied-morpho-blue-grouped-table.tsx | 3 +- src/features/positions/positions-view.tsx | 46 ++- .../queries/usePositionSnapshotsQuery.ts | 60 ++++ src/hooks/queries/useUserTransactionsQuery.ts | 102 +++++- src/hooks/usePositionReport.ts | 86 ++--- src/hooks/usePositionsWithEarnings.ts | 63 ++++ src/hooks/useUserPositionsSummaryData.ts | 293 +++++++----------- src/stores/usePositionsFilters.ts | 56 ++++ src/utils/blockEstimation.ts | 35 +++ src/utils/rpc.ts | 40 --- 12 files changed, 508 insertions(+), 357 deletions(-) delete mode 100644 app/api/block/route.ts create mode 100644 src/hooks/queries/usePositionSnapshotsQuery.ts create mode 100644 src/hooks/usePositionsWithEarnings.ts create mode 100644 src/stores/usePositionsFilters.ts create mode 100644 src/utils/blockEstimation.ts diff --git a/app/api/block/route.ts b/app/api/block/route.ts deleted file mode 100644 index 129468e2..00000000 --- a/app/api/block/route.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import type { PublicClient } from 'viem'; -import { SmartBlockFinder } from '@/utils/blockFinder'; -import type { SupportedNetworks } from '@/utils/networks'; -import { getClient } 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 Number.parseInt(data.result, 10); - } - - return null; - } catch (error) { - console.error('Etherscan API error:', error); - return null; - } -} - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const timestamp = searchParams.get('timestamp'); - const chainId = searchParams.get('chainId'); - - if (!timestamp || !chainId) { - return NextResponse.json({ error: 'Missing required parameters: timestamp and chainId' }, { status: 400 }); - } - - const numericChainId = Number.parseInt(chainId, 10); - const numericTimestamp = Number.parseInt(timestamp, 10); - - // Fallback to SmartBlockFinder - const client = getClient(numericChainId as SupportedNetworks); - - // 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), - }); - } - - if (!client) { - return NextResponse.json({ error: 'Unsupported chain ID' }, { status: 400 }); - } - - const finder = new SmartBlockFinder(client as any as PublicClient, numericChainId); - const block = await finder.findNearestBlock(numericTimestamp); - - return NextResponse.json({ - blockNumber: Number(block.number), - timestamp: Number(block.timestamp), - }); - } catch (error) { - console.error('Error finding block:', error); - return NextResponse.json({ error: 'Internal server error', details: (error as Error).message }, { status: 500 }); - } -} diff --git a/src/features/positions-report/positions-report-view.tsx b/src/features/positions-report/positions-report-view.tsx index 8148f1e6..134ea1ff 100644 --- a/src/features/positions-report/positions-report-view.tsx +++ b/src/features/positions-report/positions-report-view.tsx @@ -24,7 +24,9 @@ type ReportState = { }; export default function ReportContent({ account }: { account: Address }) { - const { loading, data: positions } = useUserPositions(account, true); + // Fetch ALL positions including closed ones (onlySupplied: false) + // This ensures report includes markets that were active during the selected period + const { loading, data: positions } = useUserPositions(account, false); const [selectedAsset, setSelectedAsset] = useState(null); // Get today's date and 2 months ago diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 48af610f..9d289fed 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -101,6 +101,7 @@ type SuppliedMorphoBlueGroupedTableProps = { isLoadingEarnings?: boolean; earningsPeriod: EarningsPeriod; setEarningsPeriod: (period: EarningsPeriod) => void; + chainBlockData?: Record; }; export function SuppliedMorphoBlueGroupedTable({ @@ -111,6 +112,7 @@ export function SuppliedMorphoBlueGroupedTable({ account, earningsPeriod, setEarningsPeriod, + chainBlockData, }: SuppliedMorphoBlueGroupedTableProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); @@ -130,7 +132,6 @@ export function SuppliedMorphoBlueGroupedTable({ }, [account, address]); const periodLabels: Record = { - all: 'All Time', day: '1D', week: '7D', month: '30D', diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index 45bd2c33..a42ed926 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -34,6 +34,8 @@ export default function Positions() { positions: marketPositions, refetch, loadingStates, + isTruncated, + actualBlockData, } = useUserPositionsSummaryData(account, earningsPeriod); // Fetch user's auto vaults @@ -52,7 +54,6 @@ export default function Positions() { 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...'; @@ -103,15 +104,40 @@ export default function Positions() { {/* Morpho Blue Positions Section */} {!loading && hasSuppliedMarkets && ( - void refetch()} - isRefetching={isRefetching} - isLoadingEarnings={isEarningsLoading} - earningsPeriod={earningsPeriod} - setEarningsPeriod={setEarningsPeriod} - /> + <> + {/* Data Truncation Warning */} + {isTruncated && ( +
+
+ ⚠️ +
+

Transaction history exceeds 1,000 entries

+

+ Earnings calculations may be incomplete.{' '} + + Use Position Report + {' '} + for complete and accurate data. +

+
+
+
+ )} + + void refetch()} + isRefetching={isRefetching} + isLoadingEarnings={isEarningsLoading} + earningsPeriod={earningsPeriod} + setEarningsPeriod={setEarningsPeriod} + chainBlockData={actualBlockData} + /> + )} {/* Auto Vaults Section (progressive loading) */} diff --git a/src/hooks/queries/usePositionSnapshotsQuery.ts b/src/hooks/queries/usePositionSnapshotsQuery.ts new file mode 100644 index 00000000..ac0f2d0c --- /dev/null +++ b/src/hooks/queries/usePositionSnapshotsQuery.ts @@ -0,0 +1,60 @@ +import { useQuery } from '@tanstack/react-query'; +import type { Address } from 'viem'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import type { SupportedNetworks } from '@/utils/networks'; +import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; +import { getClient } from '@/utils/rpc'; + +type PositionSnapshotsQueryOptions = { + /** User address to fetch snapshots for */ + user: Address; + /** Chain ID where positions exist */ + chainId: SupportedNetworks; + /** Market unique keys to fetch snapshots for */ + marketIds: string[]; + /** Block number to fetch snapshots at */ + blockNumber: number; + /** Whether to enable the query (default: true) */ + enabled?: boolean; +}; + +/** + * Fetches position snapshots at a specific block number using React Query. + * + * This hook fetches historical balances for user positions at a specific block, + * which is essential for calculating earnings over a time period. + * + * Cache behavior: + * - staleTime: 5 minutes (historical snapshots don't change) + * - Only runs when marketIds are provided and blockNumber > 0 + * + * @example + * ```tsx + * const { data: snapshots, isLoading } = usePositionSnapshotsQuery({ + * user: '0x...' as Address, + * chainId: SupportedNetworks.Mainnet, + * marketIds: ['market1', 'market2'], + * blockNumber: 19000000, + * }); + * + * const snapshot = snapshots?.get('market1'); + * const pastBalance = snapshot ? BigInt(snapshot.supplyAssets) : 0n; + * ``` + */ +export const usePositionSnapshotsQuery = (options: PositionSnapshotsQueryOptions) => { + const { user, chainId, marketIds, blockNumber, enabled = true } = options; + const { customRpcUrls } = useCustomRpcContext(); + + return useQuery, Error>({ + queryKey: ['position-snapshots', user, chainId, marketIds, blockNumber], + queryFn: async () => { + const client = getClient(chainId, customRpcUrls[chainId]); + + const snapshots = await fetchPositionsSnapshots(marketIds, user, chainId, blockNumber, client); + + return snapshots; + }, + enabled: enabled && marketIds.length > 0 && blockNumber > 0, + staleTime: 5 * 60 * 1000, // 5 minutes - historical snapshots are immutable + }); +}; diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index 03db9967..d9b43934 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -1,6 +1,25 @@ import { useQuery } from '@tanstack/react-query'; import { fetchUserTransactions, type TransactionFilters, type TransactionResponse } from './fetchUserTransactions'; +type UseUserTransactionsQueryOptions = { + filters: TransactionFilters; + enabled?: boolean; + /** + * When true, automatically paginates to fetch ALL transactions. + * Use for report generation when complete accuracy is needed. + * When false (default), fetches up to 1000 transactions and returns isTruncated flag. + * Use for summary pages when speed is prioritized. + */ + paginate?: boolean; + /** Page size for pagination (default 1000) */ + pageSize?: number; +}; + +type TransactionQueryResult = TransactionResponse & { + /** Indicates if data was truncated due to pagination limits */ + isTruncated: boolean; +}; + /** * Fetches user transactions from Morpho API or Subgraph using React Query. * @@ -9,7 +28,7 @@ import { fetchUserTransactions, type TransactionFilters, type TransactionRespons * - Falls back to Subgraph if API fails or not supported * - Combines transactions from all target networks * - Sorts by timestamp (descending) - * - Applies client-side pagination + * - Supports auto-pagination when paginate=true * * Cache behavior: * - staleTime: 30 seconds (transactions change moderately frequently) @@ -18,20 +37,32 @@ import { fetchUserTransactions, type TransactionFilters, type TransactionRespons * * @example * ```tsx - * const { data, isLoading, error } = useUserTransactionsQuery({ + * // Summary page (fast, may be truncated) + * const { data, isLoading } = useUserTransactionsQuery({ + * filters: { + * userAddress: ['0x...'], + * timestampGte: oneDayAgo, + * }, + * paginate: false, + * }); + * if (data?.isTruncated) { + * // Show warning to user + * } + * + * // Report page (complete data) + * const { data } = useUserTransactionsQuery({ * filters: { * userAddress: ['0x...'], - * chainIds: [1, 8453], - * first: 10, - * skip: 0, + * assetIds: ['0xUSDC...'], * }, + * paginate: true, * }); * ``` */ -export const useUserTransactionsQuery = (options: { filters: TransactionFilters; enabled?: boolean }) => { - const { filters, enabled = true } = options; +export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOptions) => { + const { filters, enabled = true, paginate = false, pageSize = 1000 } = options; - return useQuery({ + return useQuery({ queryKey: [ 'user-transactions', filters.userAddress, @@ -43,8 +74,61 @@ export const useUserTransactionsQuery = (options: { filters: TransactionFilters; filters.first, filters.hash, filters.assetIds, + paginate, + pageSize, ], - queryFn: () => fetchUserTransactions(filters), + queryFn: async () => { + if (!paginate) { + // Simple case: fetch once with limit + const response = await fetchUserTransactions({ + ...filters, + first: pageSize, + }); + + // Check if data was truncated (fetched items equals page size, likely more exist) + const isTruncated = response.items.length >= pageSize || response.pageInfo.countTotal > response.items.length; + + return { + ...response, + isTruncated, + }; + } + + // Pagination mode: fetch all data across multiple requests + let allItems: typeof filters extends TransactionFilters ? TransactionResponse['items'] : never = []; + let skip = 0; + let hasMore = true; + + while (hasMore) { + const response = await fetchUserTransactions({ + ...filters, + first: pageSize, + skip, + }); + + allItems = [...allItems, ...response.items]; + skip += response.items.length; + + // Stop if we got fewer items than requested (last page) + hasMore = response.items.length >= pageSize; + + // Safety: max 50 pages to prevent infinite loops + if (skip >= 50 * pageSize) { + console.warn('Transaction pagination limit reached (50 pages)'); + break; + } + } + + return { + items: allItems, + pageInfo: { + count: allItems.length, + countTotal: allItems.length, + }, + error: null, + isTruncated: false, // We fetched everything + }; + }, enabled: enabled && filters.userAddress.length > 0, staleTime: 30_000, // 30 seconds - transactions change moderately frequently refetchOnWindowFocus: true, diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 7fe38767..63adadf7 100644 --- a/src/hooks/usePositionReport.ts +++ b/src/hooks/usePositionReport.ts @@ -2,7 +2,8 @@ import type { Address } from 'viem'; import { calculateEarningsFromSnapshot, type EarningsCalculation, filterTransactionsInPeriod } from '@/utils/interest'; import type { SupportedNetworks } from '@/utils/networks'; import { fetchPositionsSnapshots } from '@/utils/positions'; -import { estimatedBlockNumber, getClient } from '@/utils/rpc'; +import { getClient } from '@/utils/rpc'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import type { Market, MarketPosition, UserTransaction } from '@/utils/types'; import { useCustomRpc } from '@/stores/useCustomRpc'; import { fetchUserTransactions } from './queries/fetchUserTransactions'; @@ -48,26 +49,30 @@ export const usePositionReport = ( endDate = new Date(); } - // fetch block number at start and end date - const { blockNumber: startBlockNumber, timestamp: startTimestamp } = await estimatedBlockNumber( - selectedAsset.chainId, - startDate.getTime() / 1000, - ); - const { blockNumber: endBlockNumber, timestamp: endTimestamp } = await estimatedBlockNumber( - selectedAsset.chainId, - endDate.getTime() / 1000, - ); - - const period = endTimestamp - startTimestamp; + // Get current block number for client-side estimation + const client = getClient(selectedAsset.chainId as SupportedNetworks, customRpcUrls[selectedAsset.chainId as SupportedNetworks]); + const currentBlock = Number(await client.getBlockNumber()); + const currentTimestamp = Math.floor(Date.now() / 1000); + + // Estimate block numbers (client-side, instant!) + const targetStartTimestamp = Math.floor(startDate.getTime() / 1000); + const targetEndTimestamp = Math.floor(endDate.getTime() / 1000); + const startBlockEstimate = estimateBlockAtTimestamp(selectedAsset.chainId, targetStartTimestamp, currentBlock, currentTimestamp); + const endBlockEstimate = estimateBlockAtTimestamp(selectedAsset.chainId, targetEndTimestamp, currentBlock, currentTimestamp); + + // Fetch ACTUAL timestamps for the estimated blocks (critical for accuracy) + const [startBlock, endBlock] = await Promise.all([ + client.getBlock({ blockNumber: BigInt(startBlockEstimate) }), + client.getBlock({ blockNumber: BigInt(endBlockEstimate) }), + ]); - const relevantPositions = positions.filter( - (position) => - position.market.loanAsset.address.toLowerCase() === selectedAsset.address.toLowerCase() && - position.market.morphoBlue.chain.id === selectedAsset.chainId, - ); + const actualStartTimestamp = Number(startBlock.timestamp); + const actualEndTimestamp = Number(endBlock.timestamp); + const period = actualEndTimestamp - actualStartTimestamp; - // Fetch all transactions with pagination - const PAGE_SIZE = 100; + // Fetch ALL transactions for this asset with auto-pagination + // Query by assetId to discover all markets (including closed ones) + const PAGE_SIZE = 1000; // Larger page size for report generation let allTransactions: UserTransaction[] = []; let hasMore = true; let skip = 0; @@ -76,9 +81,9 @@ export const usePositionReport = ( const transactionResult = await fetchUserTransactions({ userAddress: [account], chainIds: [selectedAsset.chainId], - timestampGte: startTimestamp, - timestampLte: endTimestamp, - marketUniqueKeys: relevantPositions.map((position) => position.market.uniqueKey), + timestampGte: actualStartTimestamp, // ✅ Use actual timestamp from block + timestampLte: actualEndTimestamp, // ✅ Use actual timestamp from block + assetIds: [selectedAsset.address], // Query by asset to find ALL markets first: PAGE_SIZE, skip, }); @@ -93,24 +98,23 @@ export const usePositionReport = ( hasMore = transactionResult.items.length === PAGE_SIZE; skip += PAGE_SIZE; - // Safety check to prevent infinite loops - if (skip > PAGE_SIZE * 100) { - console.warn('Reached maximum skip limit, some transactions might be missing'); + // Safety check to prevent infinite loops (50 pages = 50k transactions) + if (skip > PAGE_SIZE * 50) { + console.warn('Reached maximum pagination limit (50k transactions), some data might be missing'); break; } } - // 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, - ); + // Discover unique markets from transactions (includes closed markets) + const discoveredMarketIds = [...new Set(allTransactions.map((tx) => tx.data?.market?.uniqueKey).filter((id): id is string => !!id))]; + + // Filter positions to only those that had activity (some might be closed now) + const relevantPositions = positions.filter((position) => discoveredMarketIds.includes(position.market.uniqueKey)); // 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), + fetchPositionsSnapshots(discoveredMarketIds, account, selectedAsset.chainId, startBlockEstimate, client), + fetchPositionsSnapshots(discoveredMarketIds, account, selectedAsset.chainId, endBlockEstimate, client), ]); // Process positions with their snapshots @@ -126,16 +130,16 @@ export const usePositionReport = ( const marketTransactions = filterTransactionsInPeriod( allTransactions.filter((tx) => tx.data?.market?.uniqueKey === marketKey), - startTimestamp, - endTimestamp, + actualStartTimestamp, + actualEndTimestamp, ); const earnings = calculateEarningsFromSnapshot( BigInt(endSnapshot.supplyAssets), BigInt(startSnapshot.supplyAssets), marketTransactions, - startTimestamp, - endTimestamp, + actualStartTimestamp, + actualEndTimestamp, ); return { @@ -163,7 +167,13 @@ export const usePositionReport = ( const endBalance = marketReports.reduce((sum, report) => sum + BigInt(report.endBalance), 0n); - const groupedEarnings = calculateEarningsFromSnapshot(endBalance, startBalance, allTransactions, startTimestamp, endTimestamp); + const groupedEarnings = calculateEarningsFromSnapshot( + endBalance, + startBalance, + allTransactions, + actualStartTimestamp, + actualEndTimestamp, + ); return { totalInterestEarned, diff --git a/src/hooks/usePositionsWithEarnings.ts b/src/hooks/usePositionsWithEarnings.ts new file mode 100644 index 00000000..91468047 --- /dev/null +++ b/src/hooks/usePositionsWithEarnings.ts @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import { calculateEarningsFromSnapshot } from '@/utils/interest'; +import type { MarketPosition, UserTransaction, MarketPositionWithEarnings } from '@/utils/types'; +import type { PositionSnapshot } from '@/utils/positions'; +import type { EarningsPeriod } from '@/stores/usePositionsFilters'; + +// Simple helper for the period timestamp calculation +export const getPeriodTimestamp = (period: EarningsPeriod): number => { + const now = Math.floor(Date.now() / 1000); + const DAY = 86_400; + + switch (period) { + case 'day': + return now - DAY; + case 'week': + return now - 7 * DAY; + case 'month': + return now - 30 * DAY; + default: + return now - DAY; + } +}; + +export const usePositionsWithEarnings = ( + positions: MarketPosition[], + transactions: UserTransaction[], + snapshotsByChain: Record>, + chainBlockData: Record, + endTimestamp: number, +): MarketPositionWithEarnings[] => { + return useMemo(() => { + if (!transactions || transactions.length === 0) { + return positions.map((p) => ({ ...p, earned: '0' })); + } + + return positions.map((position) => { + const chainId = position.market.morphoBlue.chain.id; + const chainData = chainBlockData[chainId]; + const startTimestamp = chainData?.timestamp ?? 0; + + const currentBalance = BigInt(position.state.supplyAssets); + const marketIdLower = position.market.uniqueKey.toLowerCase(); + + // Get past balance from snapshot + const chainSnapshots = snapshotsByChain[chainId]; + const pastSnapshot = chainSnapshots?.get(marketIdLower); + const pastBalance = pastSnapshot ? BigInt(pastSnapshot.supplyAssets) : 0n; + + // Filter transactions for this market AND this chain's time range + const marketTxs = transactions.filter( + (tx) => + tx.data?.market?.uniqueKey?.toLowerCase() === marketIdLower && tx.timestamp >= startTimestamp && tx.timestamp <= endTimestamp, + ); + + const earnings = calculateEarningsFromSnapshot(currentBalance, pastBalance, marketTxs, startTimestamp, endTimestamp); + + return { + ...position, + earned: earnings.earned.toString(), + }; + }); + }, [positions, transactions, snapshotsByChain, chainBlockData, endTimestamp]); +}; diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 90c4b927..71c29f84 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,232 +1,163 @@ import { useMemo } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { Address } from 'viem'; -import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -import { calculateEarningsFromSnapshot } from '@/utils/interest'; -import { SupportedNetworks } from '@/utils/networks'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; -import { estimatedBlockNumber, getClient } from '@/utils/rpc'; -import type { MarketPositionWithEarnings, UserTransaction } from '@/utils/types'; import useUserPositions, { positionKeys } from './useUserPositions'; -import { fetchUserTransactions } from './queries/fetchUserTransactions'; - -export type EarningsPeriod = 'all' | 'day' | 'week' | 'month'; - -// Query keys -export const blockKeys = { - all: ['blocks'] 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, -}; - -// Helper to get timestamp for a period -const getPeriodTimestamp = (period: EarningsPeriod): number => { - const now = Math.floor(Date.now() / 1000); - const DAY = 86_400; - - switch (period) { - case 'all': - return 0; - case 'day': - return now - DAY; - case 'week': - return now - 7 * DAY; - case 'month': - return now - 30 * DAY; - default: - return 0; - } -}; - -// Fetch block number for a specific period across chains -const fetchPeriodBlockNumbers = async (period: EarningsPeriod, chainIds?: SupportedNetworks[]): Promise> => { - if (period === 'all') return {}; - - const timestamp = getPeriodTimestamp(period); - - const allNetworks = Object.values(SupportedNetworks).filter((chainId): chainId is SupportedNetworks => typeof chainId === 'number'); - const networksToFetch = chainIds ?? allNetworks; - - const blockNumbers: Record = {}; - - await Promise.all( - networksToFetch.map(async (chainId) => { - const result = await estimatedBlockNumber(chainId, timestamp); - if (result) { - blockNumbers[chainId] = result.blockNumber; - } - }), - ); - - return blockNumbers; -}; +import { useUserTransactionsQuery } from './queries/useUserTransactionsQuery'; +import { usePositionsWithEarnings, getPeriodTimestamp } from './usePositionsWithEarnings'; +import type { EarningsPeriod } from '@/stores/usePositionsFilters'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -const useUserPositionsSummaryData = (user: string | undefined, period: EarningsPeriod = 'all', chainIds?: SupportedNetworks[]) => { - const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, true, chainIds); +// Re-export EarningsPeriod for backward compatibility +export type { EarningsPeriod } from '@/stores/usePositionsFilters'; +const useUserPositionsSummaryData = (user: string | undefined, period: EarningsPeriod = 'day', chainIds?: SupportedNetworks[]) => { const queryClient = useQueryClient(); const { customRpcUrls } = useCustomRpcContext(); - // Create stable key for positions - const positionsKey = useMemo( - () => - positions - ?.map((p) => `${p.market.uniqueKey}-${p.market.morphoBlue.chain.id}`) - .sort() - .join(',') ?? '', - [positions], + const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, true, chainIds); + const uniqueChainIds = useMemo( + () => chainIds ?? [...new Set(positions?.map((p) => p.market.morphoBlue.chain.id as SupportedNetworks) ?? [])], + [chainIds, positions], ); - // 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, + const { data: currentBlocks } = useQuery({ + queryKey: ['current-blocks', uniqueChainIds], + queryFn: async () => { + const blocks: Record = {}; + await Promise.all( + uniqueChainIds.map(async (chainId) => { + try { + const client = getClient(chainId, customRpcUrls[chainId]); + const blockNumber = await client.getBlockNumber(); + blocks[chainId] = Number(blockNumber); + } catch (error) { + console.error(`Failed to get current block for chain ${chainId}:`, error); + } + }), + ); + return blocks; + }, + enabled: uniqueChainIds.length > 0, + staleTime: 30_000, + gcTime: 60_000, }); - // 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(); + const snapshotBlocks = useMemo(() => { + if (!currentBlocks) return {}; - // 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); - }); + const timestamp = getPeriodTimestamp(period); + const blocks: Record = {}; - // 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; + uniqueChainIds.forEach((chainId) => { + const currentBlock = currentBlocks[chainId]; + if (currentBlock) { + blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); + } + }); - const client = getClient(chainId as SupportedNetworks, customRpcUrls[chainId as SupportedNetworks]); + return blocks; + }, [period, uniqueChainIds, currentBlocks]); - const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainId, blockNumber, client); + const { data: actualBlockData } = useQuery({ + queryKey: ['block-timestamps', snapshotBlocks], + queryFn: async () => { + const blockData: Record = {}; - snapshots.forEach((snapshot, marketId) => { - allSnapshots.set(marketId.toLowerCase(), snapshot); - }); + await Promise.all( + Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { + try { + const client = getClient(Number(chainId) as SupportedNetworks, customRpcUrls[Number(chainId) as SupportedNetworks]); + const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); + blockData[Number(chainId)] = { + block: blockNum, + timestamp: Number(block.timestamp), + }; + } catch (error) { + console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); + } }), ); - return allSnapshots; + return blockData; }, - enabled: !!positions && !!user && (period === 'all' || !!periodBlockNumbers), - staleTime: 30_000, - gcTime: 5 * 60 * 1000, + enabled: Object.keys(snapshotBlocks).length > 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, }); - // Query for all transactions (independent of period) - const uniqueChainIds = useMemo( - () => chainIds ?? [...new Set(positions?.map((p) => p.market.morphoBlue.chain.id as SupportedNetworks) ?? [])], - [chainIds, positions], - ); - - const { data: transactionResponse, isLoading: isLoadingTransactions } = useQuery({ - queryKey: [ - 'user-transactions', - user ? [user] : [], - positions?.map((p) => p.market.uniqueKey), - uniqueChainIds, - undefined, // timestampGte - undefined, // timestampLte - undefined, // skip - undefined, // first - undefined, // hash - undefined, // assetIds - ], - queryFn: async () => { - if (!positions || !user) return { items: [], pageInfo: { count: 0, countTotal: 0 }, error: null }; - - const result = await fetchUserTransactions({ - userAddress: [user], - marketUniqueKeys: positions.map((p) => p.market.uniqueKey), - chainIds: uniqueChainIds, - }); + const endTimestamp = useMemo(() => Math.floor(Date.now() / 1000), []); - return result; + const { data: txData, isLoading: isLoadingTransactions } = useUserTransactionsQuery({ + filters: { + userAddress: user ? [user] : [], + marketUniqueKeys: positions?.map((p) => p.market.uniqueKey), + chainIds: uniqueChainIds, }, + paginate: false, enabled: !!positions && !!user, - staleTime: 60_000, // 1 minute - gcTime: 5 * 60 * 1000, }); - const allTransactions = transactionResponse?.items ?? []; - - // Calculate earnings from snapshots + transactions - const positionsWithEarnings = useMemo((): MarketPositionWithEarnings[] => { - if (!positions) return []; + const { data: allSnapshots, isLoading: isLoadingSnapshots } = useQuery({ + queryKey: ['all-position-snapshots', snapshotBlocks, user, positions?.map((p) => p.market.uniqueKey)], + queryFn: async () => { + if (!positions || !user) return {}; - // 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 snapshotsByChain: Record> = {}; - // 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' })); - } + await Promise.all( + Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { + const chainIdNum = Number(chainId); + const chainPositions = positions.filter((p) => p.market.morphoBlue.chain.id === chainIdNum); - const now = Math.floor(Date.now() / 1000); - const startTimestamp = getPeriodTimestamp(period); + if (chainPositions.length === 0) return; - return positions.map((position) => { - const currentBalance = BigInt(position.state.supplyAssets); - const marketId = position.market.uniqueKey; - const marketIdLower = marketId.toLowerCase(); + const client = getClient(chainIdNum as SupportedNetworks, customRpcUrls[chainIdNum as SupportedNetworks]); + const marketIds = chainPositions.map((p) => p.market.uniqueKey); - // Get past balance from snapshot (0 for lifetime) - const pastSnapshot = periodSnapshots?.get(marketIdLower); - const pastBalance = pastSnapshot ? BigInt(pastSnapshot.supplyAssets) : 0n; + const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockNum, client); - // Filter transactions for this market (case-insensitive comparison) - const marketTxs = (allTransactions ?? []).filter( - (tx: UserTransaction) => tx.data?.market?.uniqueKey?.toLowerCase() === marketIdLower, + snapshotsByChain[chainIdNum] = snapshots; + }), ); - // Calculate earnings - const earnings = calculateEarningsFromSnapshot(currentBalance, pastBalance, marketTxs, startTimestamp, now); + return snapshotsByChain; + }, + enabled: !!positions && !!user && Object.keys(snapshotBlocks).length > 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); - return { - ...position, - earned: earnings.earned.toString(), - }; - }); - }, [positions, periodSnapshots, allTransactions, period]); + const positionsWithEarnings = usePositionsWithEarnings( + positions ?? [], + txData?.items ?? [], + allSnapshots ?? {}, + actualBlockData ?? {}, + endTimestamp, + ); const refetch = async (onSuccess?: () => void) => { try { - // Invalidate positions await queryClient.invalidateQueries({ queryKey: positionKeys.initialData(user ?? ''), }); await queryClient.invalidateQueries({ queryKey: ['enhanced-positions', user], }); - // Invalidate snapshots await queryClient.invalidateQueries({ - queryKey: ['period-snapshots', user], + queryKey: ['all-position-snapshots'], + }); + await queryClient.invalidateQueries({ + queryKey: ['user-transactions'], + }); + await queryClient.invalidateQueries({ + queryKey: ['current-blocks'], }); - // Invalidate transactions await queryClient.invalidateQueries({ - queryKey: ['user-transactions', user ? [user] : []], + queryKey: ['block-timestamps'], }); onSuccess?.(); @@ -235,12 +166,10 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP } }; - const isEarningsLoading = isLoadingBlocks || isLoadingSnapshots || isLoadingTransactions; + const isEarningsLoading = isLoadingSnapshots || isLoadingTransactions || !actualBlockData; - // Detailed loading states for UI const loadingStates = { positions: positionsLoading, - blocks: isLoadingBlocks, snapshots: isLoadingSnapshots, transactions: isLoadingTransactions, }; @@ -250,9 +179,11 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP isPositionsLoading: positionsLoading, isEarningsLoading, isRefetching, + isTruncated: txData?.isTruncated ?? false, error: positionsError, refetch, loadingStates, + actualBlockData: actualBlockData ?? {}, }; }; diff --git a/src/stores/usePositionsFilters.ts b/src/stores/usePositionsFilters.ts new file mode 100644 index 00000000..9fe664d3 --- /dev/null +++ b/src/stores/usePositionsFilters.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +/** + * Earnings calculation periods for the positions summary page. + * Removed 'all' to optimize for speed - use report page for comprehensive analysis. + */ +export type EarningsPeriod = 'day' | 'week' | 'month'; + +type PositionsFiltersState = { + /** Currently selected earnings period */ + period: EarningsPeriod; +}; + +type PositionsFiltersActions = { + /** Set the earnings period */ + setPeriod: (period: EarningsPeriod) => void; + + /** Reset to default state */ + reset: () => void; +}; + +type PositionsFiltersStore = PositionsFiltersState & PositionsFiltersActions; + +const DEFAULT_STATE: PositionsFiltersState = { + period: 'day', +}; + +/** + * Zustand store for positions page filters. + * Persists user's selected earnings period across sessions. + * + * @example + * ```tsx + * // Separate selectors for optimal re-renders + * const period = usePositionsFilters((s) => s.period); + * const setPeriod = usePositionsFilters((s) => s.setPeriod); + * + * + * ``` + */ +export const usePositionsFilters = create()( + persist( + (set) => ({ + // Default state + ...DEFAULT_STATE, + + // Actions + setPeriod: (period) => set({ period }), + reset: () => set(DEFAULT_STATE), + }), + { + name: 'monarch_store_positionsFilters', + }, + ), +); diff --git a/src/utils/blockEstimation.ts b/src/utils/blockEstimation.ts new file mode 100644 index 00000000..3d7c2c5c --- /dev/null +++ b/src/utils/blockEstimation.ts @@ -0,0 +1,35 @@ +import { type SupportedNetworks, getBlocktime } from './networks'; + +/** + * Estimates the block number at a given timestamp using average block times. + * This provides a quick approximation that's then refined by fetching the actual block timestamp. + * + * @param chainId - The chain ID to estimate for + * @param targetTimestamp - The Unix timestamp (in seconds) to estimate the block for + * @param currentBlock - The current block number on the chain + * @param currentTimestamp - The current Unix timestamp (in seconds), defaults to now + * @returns The estimated block number at the target timestamp + * + * @example + * // Estimate block number 24 hours ago + * const oneDayAgo = Math.floor(Date.now() / 1000) - 86400; + * const estimatedBlock = estimateBlockAtTimestamp( + * SupportedNetworks.Mainnet, + * oneDayAgo, + * 19000000 + * ); + * // Returns approximately 19000000 - 7200 (24h / 12s per block) + */ +export const estimateBlockAtTimestamp = ( + chainId: SupportedNetworks, + targetTimestamp: number, + currentBlock: number, + currentTimestamp: number = Math.floor(Date.now() / 1000), +): number => { + const timeDiff = currentTimestamp - targetTimestamp; + const blockTime = getBlocktime(chainId); // Use existing utility from networks.ts + const blockDiff = Math.floor(timeDiff / blockTime); + + // Ensure we don't return negative block numbers + return Math.max(0, currentBlock - blockDiff); +}; diff --git a/src/utils/rpc.ts b/src/utils/rpc.ts index 9c39328c..0376f4de 100644 --- a/src/utils/rpc.ts +++ b/src/utils/rpc.ts @@ -67,43 +67,3 @@ export const getClient = (chainId: SupportedNetworks, customRpcUrl?: string): Pu } return client; }; - -type BlockResponse = { - blockNumber: string; - timestamp: number; - approximateBlockTime: number; -}; - -export async function estimatedBlockNumber( - chainId: SupportedNetworks, - timestamp: number, -): Promise<{ - blockNumber: number; - timestamp: number; -}> { - const fetchBlock = async () => { - const blockResponse = await fetch(`/api/block?timestamp=${encodeURIComponent(timestamp)}&chainId=${encodeURIComponent(chainId)}`); - - if (!blockResponse.ok) { - const errorData = (await blockResponse.json()) as { error?: string }; - console.error('Failed to find nearest block:', errorData); - throw new Error('Failed to find nearest block'); - } - - const blockData = (await blockResponse.json()) as BlockResponse; - console.log('Found nearest block:', blockData); - - return { - blockNumber: Number(blockData.blockNumber), - timestamp: Number(blockData.timestamp), - }; - }; - - try { - return await fetchBlock(); - } catch (_error) { - console.log('First attempt failed, retrying in 2 seconds...'); - await new Promise((resolve) => setTimeout(resolve, 2000)); - return await fetchBlock(); - } -} From 88728bb6ca053f805601e02cfe03185ccd279230 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 16:53:31 +0800 Subject: [PATCH 02/11] refactor: remove isTruncated --- docs/Styling.md | 1 - src/data-sources/morpho-api/transactions.ts | 1 - src/data-sources/subgraph/transactions.ts | 17 ++-- .../components/report-table.tsx | 5 + .../positions-report-view.tsx | 4 +- .../components/collateral-icons-display.tsx | 2 +- src/features/positions/positions-view.tsx | 45 ++------- src/graphql/morpho-subgraph-queries.ts | 6 ++ src/hooks/queries/useBlocksAtTimestamp.ts | 94 +++++++++++++++++++ src/hooks/queries/useCurrentBlocks.ts | 55 +++++++++++ src/hooks/queries/useUserTransactionsQuery.ts | 25 +---- src/hooks/usePositionReport.ts | 8 ++ src/hooks/useUserPositions.ts | 12 ++- src/hooks/useUserPositionsSummaryData.ts | 87 +++-------------- 14 files changed, 219 insertions(+), 143 deletions(-) create mode 100644 src/hooks/queries/useBlocksAtTimestamp.ts create mode 100644 src/hooks/queries/useCurrentBlocks.ts diff --git a/docs/Styling.md b/docs/Styling.md index abf6d9cb..c36632de 100644 --- a/docs/Styling.md +++ b/docs/Styling.md @@ -889,7 +889,6 @@ This component follows the same overlapping icon pattern as `TrustedByCell` in ` - First icon has `ml-0`, subsequent icons have `-ml-2` for overlapping effect - Z-index decreases from left to right for proper stacking - "+X more" badge shows remaining items in tooltip -- Empty state shows "No known collaterals" message **Examples in codebase:** - `src/features/positions/components/supplied-morpho-blue-grouped-table.tsx` diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 348b58d1..7d91402d 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -19,7 +19,6 @@ export const fetchMorphoTransactions = async (filters: TransactionFilters): Prom chainId_in: filters.chainIds ?? [SupportedNetworks.Base, SupportedNetworks.Mainnet], }; - // disable cuz it's too long if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { whereClause.marketUniqueKey_in = filters.marketUniqueKeys; } diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts index 0188b0b1..7ea5f6ab 100644 --- a/src/data-sources/subgraph/transactions.ts +++ b/src/data-sources/subgraph/transactions.ts @@ -112,17 +112,12 @@ const transformSubgraphTransactions = ( allTransactions.sort((a, b) => b.timestamp - a.timestamp); - // marketUniqueKeys is empty: all markets - const filteredTransactions = - filters.marketUniqueKeys?.length === 0 - ? allTransactions - : allTransactions.filter((tx) => filters.marketUniqueKeys?.includes(tx.data.market.uniqueKey)); - - const count = filteredTransactions.length; + // No client-side filtering needed - filtering is done at GraphQL level via market_in + const count = allTransactions.length; const countTotal = count; return { - items: filteredTransactions, + items: allTransactions, pageInfo: { count: count, countTotal: countTotal, @@ -167,6 +162,12 @@ export const fetchSubgraphTransactions = async (filters: TransactionFilters, net timestamp_lt: currentTimestamp, // Always end at current time }; + // Add market_in filter if marketUniqueKeys are provided + if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { + // Convert market keys to lowercase for subgraph compatibility + variables.market_in = filters.marketUniqueKeys.map((key) => key.toLowerCase()); + } + if (filters.timestampGte !== undefined && filters.timestampGte !== null) { variables.timestamp_gte = filters.timestampGte; } diff --git a/src/features/positions-report/components/report-table.tsx b/src/features/positions-report/components/report-table.tsx index b86c3c2b..16a95822 100644 --- a/src/features/positions-report/components/report-table.tsx +++ b/src/features/positions-report/components/report-table.tsx @@ -150,6 +150,11 @@ export function ReportTable({ report, asset, startDate, endDate, chainId }: Repo Generated for {asset.symbol} from{' '} {formatter.format(startDate.toDate(getLocalTimeZone()))} to {formatter.format(endDate.toDate(getLocalTimeZone()))}

+ {report.startBlock && report.endBlock && ( +

+ Calculated from block {report.startBlock.toLocaleString()} to {report.endBlock.toLocaleString()} +

+ )}
diff --git a/src/features/positions-report/positions-report-view.tsx b/src/features/positions-report/positions-report-view.tsx index 134ea1ff..2fde0e9b 100644 --- a/src/features/positions-report/positions-report-view.tsx +++ b/src/features/positions-report/positions-report-view.tsx @@ -24,9 +24,9 @@ type ReportState = { }; export default function ReportContent({ account }: { account: Address }) { - // Fetch ALL positions including closed ones (onlySupplied: false) + // Fetch ALL positions including closed ones (showEmpty: true) // This ensures report includes markets that were active during the selected period - const { loading, data: positions } = useUserPositions(account, false); + const { loading, data: positions } = useUserPositions(account, true); const [selectedAsset, setSelectedAsset] = useState(null); // Get today's date and 2 months ago diff --git a/src/features/positions/components/collateral-icons-display.tsx b/src/features/positions/components/collateral-icons-display.tsx index ba8e40ba..2524988b 100644 --- a/src/features/positions/components/collateral-icons-display.tsx +++ b/src/features/positions/components/collateral-icons-display.tsx @@ -83,7 +83,7 @@ type CollateralIconsDisplayProps = { */ export function CollateralIconsDisplay({ collaterals, chainId, maxDisplay = 8, iconSize = 20 }: CollateralIconsDisplayProps) { if (collaterals.length === 0) { - return No known collaterals; + return - ; } // Sort by amount descending diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index a42ed926..cf8a79bd 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -34,7 +34,6 @@ export default function Positions() { positions: marketPositions, refetch, loadingStates, - isTruncated, actualBlockData, } = useUserPositionsSummaryData(account, earningsPeriod); @@ -104,40 +103,16 @@ export default function Positions() { {/* Morpho Blue Positions Section */} {!loading && hasSuppliedMarkets && ( - <> - {/* Data Truncation Warning */} - {isTruncated && ( -
-
- ⚠️ -
-

Transaction history exceeds 1,000 entries

-

- Earnings calculations may be incomplete.{' '} - - Use Position Report - {' '} - for complete and accurate data. -

-
-
-
- )} - - void refetch()} - isRefetching={isRefetching} - isLoadingEarnings={isEarningsLoading} - earningsPeriod={earningsPeriod} - setEarningsPeriod={setEarningsPeriod} - chainBlockData={actualBlockData} - /> - + void refetch()} + isRefetching={isRefetching} + isLoadingEarnings={isEarningsLoading} + earningsPeriod={earningsPeriod} + setEarningsPeriod={setEarningsPeriod} + chainBlockData={actualBlockData} + /> )} {/* Auto Vaults Section (progressive loading) */} diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 3000fb3e..697f6a62 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -301,6 +301,7 @@ export const subgraphUserTransactionsQuery = ` $skip: Int! $timestamp_gt: BigInt! # Always filter from timestamp 0 $timestamp_lt: BigInt! # Always filter up to current time + $market_in: [Bytes!] # Optional market filter ) { account(id: $userId) { deposits( @@ -311,6 +312,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + market_in: $market_in } ) { id @@ -331,6 +333,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + market_in: $market_in } ) { id @@ -351,6 +354,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + market_in: $market_in } ) { id @@ -370,6 +374,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + market_in: $market_in } ) { id @@ -389,6 +394,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + market_in: $market_in } ) { id diff --git a/src/hooks/queries/useBlocksAtTimestamp.ts b/src/hooks/queries/useBlocksAtTimestamp.ts new file mode 100644 index 00000000..aead3c13 --- /dev/null +++ b/src/hooks/queries/useBlocksAtTimestamp.ts @@ -0,0 +1,94 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; + +/** + * Estimates and fetches blocks at a specific timestamp across multiple chains. + * + * Two-step process: + * 1. Client-side estimation using estimateBlockAtTimestamp (instant) + * 2. On-chain verification by fetching actual block timestamps + * + * This ensures accurate time ranges for historical queries while providing + * fast initial estimates. + * + * Cache behavior: + * - staleTime: 5 minutes (historical blocks don't change) + * - gcTime: 10 minutes + * - Only runs when currentBlocks are available + * + * @param timestamp - Target timestamp (seconds since epoch) + * @param chainIds - Array of chain IDs to estimate blocks for + * @param currentBlocks - Current block numbers from useCurrentBlocks + * @param customRpcUrls - Optional custom RPC URLs by chain ID + * @returns React Query result with Record + * + * @example + * ```tsx + * const oneDayAgo = Math.floor(Date.now() / 1000) - 86400; + * const { data: blocks } = useBlocksAtTimestamp( + * oneDayAgo, + * [1, 8453], + * currentBlocks, + * customRpcUrls + * ); + * // blocks = { + * // 1: { block: 12340000, timestamp: 1234567890 }, + * // 8453: { block: 9870000, timestamp: 1234567892 } + * // } + * ``` + */ +export const useBlocksAtTimestamp = ( + timestamp: number, + chainIds: SupportedNetworks[], + currentBlocks: Record | undefined, + customRpcUrls?: Record, +) => { + // Step 1: Client-side estimation (instant, no RPC calls) + const estimatedBlocks = useMemo(() => { + if (!currentBlocks) return {}; + + const blocks: Record = {}; + chainIds.forEach((chainId) => { + const currentBlock = currentBlocks[chainId]; + if (currentBlock) { + blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); + } + }); + + return blocks; + }, [timestamp, chainIds, currentBlocks]); + + // Step 2: Fetch actual blocks to verify timestamps + return useQuery({ + queryKey: ['blocks-at-timestamp', timestamp, chainIds.sort().join(',')], + queryFn: async () => { + const blockData: Record = {}; + + await Promise.all( + Object.entries(estimatedBlocks).map(async ([chainId, blockNum]) => { + try { + const client = getClient( + Number(chainId) as SupportedNetworks, + customRpcUrls?.[Number(chainId) as SupportedNetworks], + ); + const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); + blockData[Number(chainId)] = { + block: blockNum, + timestamp: Number(block.timestamp), + }; + } catch (error) { + console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); + } + }), + ); + + return blockData; + }, + enabled: !!currentBlocks && Object.keys(estimatedBlocks).length > 0, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + }); +}; diff --git a/src/hooks/queries/useCurrentBlocks.ts b/src/hooks/queries/useCurrentBlocks.ts new file mode 100644 index 00000000..4bf7fbb5 --- /dev/null +++ b/src/hooks/queries/useCurrentBlocks.ts @@ -0,0 +1,55 @@ +import { useQuery } from '@tanstack/react-query'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; + +/** + * Fetches current block numbers for the specified chains. + * + * This hook provides real-time block numbers that can be used for: + * - Block estimation calculations + * - Snapshot queries at current state + * - Time-to-block conversions + * + * Cache behavior: + * - staleTime: 30 seconds (blocks change frequently) + * - gcTime: 60 seconds + * - Refetches automatically when chains change + * + * @param chainIds - Array of chain IDs to fetch blocks for + * @param customRpcUrls - Optional custom RPC URLs by chain ID + * @returns React Query result with Record + * + * @example + * ```tsx + * const { data: currentBlocks } = useCurrentBlocks([1, 8453], customRpcUrls); + * // currentBlocks = { 1: 12345678, 8453: 9876543 } + * ``` + */ +export const useCurrentBlocks = ( + chainIds: SupportedNetworks[], + customRpcUrls?: Record, +) => { + return useQuery({ + queryKey: ['current-blocks', chainIds.sort().join(',')], + queryFn: async () => { + const blocks: Record = {}; + + await Promise.all( + chainIds.map(async (chainId) => { + try { + const client = getClient(chainId, customRpcUrls?.[chainId]); + const blockNumber = await client.getBlockNumber(); + blocks[chainId] = Number(blockNumber); + } catch (error) { + console.error(`Failed to get current block for chain ${chainId}:`, error); + } + }), + ); + + return blocks; + }, + enabled: chainIds.length > 0, + staleTime: 30_000, // 30 seconds + gcTime: 60_000, // 1 minute + }); +}; diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index d9b43934..862be16e 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -7,7 +7,7 @@ type UseUserTransactionsQueryOptions = { /** * When true, automatically paginates to fetch ALL transactions. * Use for report generation when complete accuracy is needed. - * When false (default), fetches up to 1000 transactions and returns isTruncated flag. + * When false (default), fetches up to 1000 transactions (single page). * Use for summary pages when speed is prioritized. */ paginate?: boolean; @@ -15,10 +15,7 @@ type UseUserTransactionsQueryOptions = { pageSize?: number; }; -type TransactionQueryResult = TransactionResponse & { - /** Indicates if data was truncated due to pagination limits */ - isTruncated: boolean; -}; +type TransactionQueryResult = TransactionResponse; /** * Fetches user transactions from Morpho API or Subgraph using React Query. @@ -37,7 +34,7 @@ type TransactionQueryResult = TransactionResponse & { * * @example * ```tsx - * // Summary page (fast, may be truncated) + * // Summary page (fast, single page) * const { data, isLoading } = useUserTransactionsQuery({ * filters: { * userAddress: ['0x...'], @@ -45,11 +42,8 @@ type TransactionQueryResult = TransactionResponse & { * }, * paginate: false, * }); - * if (data?.isTruncated) { - * // Show warning to user - * } * - * // Report page (complete data) + * // Report page (complete data with auto-pagination) * const { data } = useUserTransactionsQuery({ * filters: { * userAddress: ['0x...'], @@ -80,18 +74,10 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption queryFn: async () => { if (!paginate) { // Simple case: fetch once with limit - const response = await fetchUserTransactions({ + return await fetchUserTransactions({ ...filters, first: pageSize, }); - - // Check if data was truncated (fetched items equals page size, likely more exist) - const isTruncated = response.items.length >= pageSize || response.pageInfo.countTotal > response.items.length; - - return { - ...response, - isTruncated, - }; } // Pagination mode: fetch all data across multiple requests @@ -126,7 +112,6 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption countTotal: allItems.length, }, error: null, - isTruncated: false, // We fetched everything }; }, enabled: enabled && filters.userAddress.length > 0, diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 63adadf7..6c92129e 100644 --- a/src/hooks/usePositionReport.ts +++ b/src/hooks/usePositionReport.ts @@ -28,6 +28,10 @@ export type ReportSummary = { period: number; marketReports: PositionReport[]; groupedEarnings: EarningsCalculation; + startBlock: number; + endBlock: number; + startTimestamp: number; + endTimestamp: number; }; export const usePositionReport = ( @@ -182,6 +186,10 @@ export const usePositionReport = ( period, marketReports, groupedEarnings, + startBlock: startBlockEstimate, + endBlock: endBlockEstimate, + startTimestamp: actualStartTimestamp, + endTimestamp: actualEndTimestamp, }; }; diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index d9d2256e..a61485fe 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -38,7 +38,8 @@ export const positionKeys = { snapshot: (marketKey: string, userAddress: string, chainId: number) => [...positionKeys.all, 'snapshot', marketKey, userAddress, chainId] as const, // Key for the final enhanced position data, dependent on initialData result - enhanced: (user: string | undefined, initialData: InitialDataResponse | undefined) => + // marketsCount triggers re-fetch when markets finish loading + enhanced: (user: string | undefined, initialData: InitialDataResponse | undefined, marketsCount: number) => [ 'enhanced-positions', user, @@ -46,6 +47,7 @@ export const positionKeys = { .map((k) => `${k.marketUniqueKey.toLowerCase()}-${k.chainId}`) .sort() .join(','), + marketsCount, ] as const, }; @@ -67,6 +69,8 @@ const fetchSourceMarketKeys = async (user: string, chainIds?: SupportedNetworks[ try { console.log(`Attempting to fetch positions via Morpho API for network ${network}`); markets = await fetchMorphoUserPositionMarkets(user, network); + + console.log('Fetched market keys for network', network, markets.length) } catch (morphoError) { console.error(`Failed to fetch positions via Morpho API for network ${network}:`, morphoError); // Continue to Subgraph fallback @@ -140,7 +144,7 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? // console.log(`[Positions] Query 1: Final unique keys count: ${finalMarketKeys.length}`); return { finalMarketKeys }; }, - enabled: !!user && allMarkets.length > 0, + enabled: !!user, staleTime: 0, }); @@ -150,7 +154,7 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? isLoading: isLoadingEnhanced, isRefetching: isRefetchingEnhanced, } = useQuery({ - queryKey: positionKeys.enhanced(user, initialData), + queryKey: positionKeys.enhanced(user, initialData, allMarkets.length), queryFn: async () => { if (!initialData || !user) throw new Error('Assertion failed: initialData/user should be defined here.'); @@ -166,6 +170,8 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? marketsByChain.set(marketInfo.chainId, existing); }); + console.log('All markets by chain', marketsByChain) + // Build market data map from allMarkets context (no need to fetch individually) const marketDataMap = new Map(); allMarkets.forEach((market) => { diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 71c29f84..2586073e 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,7 +1,6 @@ import { useMemo } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { Address } from 'viem'; -import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; @@ -10,6 +9,8 @@ import { useUserTransactionsQuery } from './queries/useUserTransactionsQuery'; import { usePositionsWithEarnings, getPeriodTimestamp } from './usePositionsWithEarnings'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import { useCurrentBlocks } from './queries/useCurrentBlocks'; +import { useBlocksAtTimestamp } from './queries/useBlocksAtTimestamp'; // Re-export EarningsPeriod for backward compatibility export type { EarningsPeriod } from '@/stores/usePositionsFilters'; @@ -18,76 +19,19 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP const queryClient = useQueryClient(); const { customRpcUrls } = useCustomRpcContext(); - const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, true, chainIds); + // Only fetch opened positions + const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, false, chainIds); + const uniqueChainIds = useMemo( () => chainIds ?? [...new Set(positions?.map((p) => p.market.morphoBlue.chain.id as SupportedNetworks) ?? [])], [chainIds, positions], ); - const { data: currentBlocks } = useQuery({ - queryKey: ['current-blocks', uniqueChainIds], - queryFn: async () => { - const blocks: Record = {}; - await Promise.all( - uniqueChainIds.map(async (chainId) => { - try { - const client = getClient(chainId, customRpcUrls[chainId]); - const blockNumber = await client.getBlockNumber(); - blocks[chainId] = Number(blockNumber); - } catch (error) { - console.error(`Failed to get current block for chain ${chainId}:`, error); - } - }), - ); - return blocks; - }, - enabled: uniqueChainIds.length > 0, - staleTime: 30_000, - gcTime: 60_000, - }); - - const snapshotBlocks = useMemo(() => { - if (!currentBlocks) return {}; - - const timestamp = getPeriodTimestamp(period); - const blocks: Record = {}; - - uniqueChainIds.forEach((chainId) => { - const currentBlock = currentBlocks[chainId]; - if (currentBlock) { - blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); - } - }); - - return blocks; - }, [period, uniqueChainIds, currentBlocks]); + // Use extracted block hooks for cleaner code + const { data: currentBlocks } = useCurrentBlocks(uniqueChainIds, customRpcUrls); - const { data: actualBlockData } = useQuery({ - queryKey: ['block-timestamps', snapshotBlocks], - queryFn: async () => { - const blockData: Record = {}; - - await Promise.all( - Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { - try { - const client = getClient(Number(chainId) as SupportedNetworks, customRpcUrls[Number(chainId) as SupportedNetworks]); - const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); - blockData[Number(chainId)] = { - block: blockNum, - timestamp: Number(block.timestamp), - }; - } catch (error) { - console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); - } - }), - ); - - return blockData; - }, - enabled: Object.keys(snapshotBlocks).length > 0, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - }); + const periodTimestamp = useMemo(() => getPeriodTimestamp(period), [period]); + const { data: actualBlockData } = useBlocksAtTimestamp(periodTimestamp, uniqueChainIds, currentBlocks, customRpcUrls); const endTimestamp = useMemo(() => Math.floor(Date.now() / 1000), []); @@ -97,19 +41,19 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP marketUniqueKeys: positions?.map((p) => p.market.uniqueKey), chainIds: uniqueChainIds, }, - paginate: false, + paginate: true, // Always fetch all transactions for accuracy enabled: !!positions && !!user, }); const { data: allSnapshots, isLoading: isLoadingSnapshots } = useQuery({ - queryKey: ['all-position-snapshots', snapshotBlocks, user, positions?.map((p) => p.market.uniqueKey)], + queryKey: ['all-position-snapshots', actualBlockData, user, positions?.map((p) => p.market.uniqueKey)], queryFn: async () => { - if (!positions || !user) return {}; + if (!positions || !user || !actualBlockData) return {}; const snapshotsByChain: Record> = {}; await Promise.all( - Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { + Object.entries(actualBlockData).map(async ([chainId, blockData]) => { const chainIdNum = Number(chainId); const chainPositions = positions.filter((p) => p.market.morphoBlue.chain.id === chainIdNum); @@ -118,7 +62,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP const client = getClient(chainIdNum as SupportedNetworks, customRpcUrls[chainIdNum as SupportedNetworks]); const marketIds = chainPositions.map((p) => p.market.uniqueKey); - const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockNum, client); + const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockData.block, client); snapshotsByChain[chainIdNum] = snapshots; }), @@ -126,7 +70,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP return snapshotsByChain; }, - enabled: !!positions && !!user && Object.keys(snapshotBlocks).length > 0, + enabled: !!positions && !!user && !!actualBlockData && Object.keys(actualBlockData).length > 0, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }); @@ -179,7 +123,6 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP isPositionsLoading: positionsLoading, isEarningsLoading, isRefetching, - isTruncated: txData?.isTruncated ?? false, error: positionsError, refetch, loadingStates, From b2a5a7d170498003c52d5119e2627bc972d8e0ad Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 17:32:23 +0800 Subject: [PATCH 03/11] chore: test --- src/data-sources/morpho-api/transactions.ts | 8 +- .../positions-report-view.tsx | 4 +- .../supplied-morpho-blue-grouped-table.tsx | 13 +++ src/features/positions/positions-view.tsx | 2 + src/hooks/queries/useBlocksAtTimestamp.ts | 94 ------------------- src/hooks/queries/useCurrentBlocks.ts | 55 ----------- src/hooks/queries/useUserTransactionsQuery.ts | 27 +++++- src/hooks/useUserPositions.ts | 10 +- src/hooks/useUserPositionsSummaryData.ts | 85 ++++++++++++++--- 9 files changed, 119 insertions(+), 179 deletions(-) delete mode 100644 src/hooks/queries/useBlocksAtTimestamp.ts delete mode 100644 src/hooks/queries/useCurrentBlocks.ts diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 7d91402d..1dc0c6be 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -19,9 +19,9 @@ export const fetchMorphoTransactions = async (filters: TransactionFilters): Prom chainId_in: filters.chainIds ?? [SupportedNetworks.Base, SupportedNetworks.Mainnet], }; - if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { - whereClause.marketUniqueKey_in = filters.marketUniqueKeys; - } + // if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { + // whereClause.marketUniqueKey_in = filters.marketUniqueKeys; + // } if (filters.timestampGte !== undefined && filters.timestampGte !== null) { whereClause.timestamp_gte = filters.timestampGte; } @@ -36,6 +36,8 @@ export const fetchMorphoTransactions = async (filters: TransactionFilters): Prom } try { + console.log('try', whereClause) + const result = await morphoGraphqlFetcher(userTransactionsQuery, { where: whereClause, first: filters.first ?? 1000, diff --git a/src/features/positions-report/positions-report-view.tsx b/src/features/positions-report/positions-report-view.tsx index 2fde0e9b..134ea1ff 100644 --- a/src/features/positions-report/positions-report-view.tsx +++ b/src/features/positions-report/positions-report-view.tsx @@ -24,9 +24,9 @@ type ReportState = { }; export default function ReportContent({ account }: { account: Address }) { - // Fetch ALL positions including closed ones (showEmpty: true) + // Fetch ALL positions including closed ones (onlySupplied: false) // This ensures report includes markets that were active during the selected period - const { loading, data: positions } = useUserPositions(account, true); + const { loading, data: positions } = useUserPositions(account, false); const [selectedAsset, setSelectedAsset] = useState(null); // Get today's date and 2 months ago diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 9d289fed..b617160d 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -102,6 +102,7 @@ type SuppliedMorphoBlueGroupedTableProps = { earningsPeriod: EarningsPeriod; setEarningsPeriod: (period: EarningsPeriod) => void; chainBlockData?: Record; + isTruncated?: boolean; }; export function SuppliedMorphoBlueGroupedTable({ @@ -113,6 +114,7 @@ export function SuppliedMorphoBlueGroupedTable({ earningsPeriod, setEarningsPeriod, chainBlockData, + isTruncated, }: SuppliedMorphoBlueGroupedTableProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); @@ -307,6 +309,17 @@ export function SuppliedMorphoBlueGroupedTable({ margin={3} />
+ ) : isTruncated ? ( + + } + > + - + ) : ( {(() => { diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index cf8a79bd..19f2f488 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -34,6 +34,7 @@ export default function Positions() { positions: marketPositions, refetch, loadingStates, + isTruncated, actualBlockData, } = useUserPositionsSummaryData(account, earningsPeriod); @@ -112,6 +113,7 @@ export default function Positions() { earningsPeriod={earningsPeriod} setEarningsPeriod={setEarningsPeriod} chainBlockData={actualBlockData} + isTruncated={isTruncated} /> )} diff --git a/src/hooks/queries/useBlocksAtTimestamp.ts b/src/hooks/queries/useBlocksAtTimestamp.ts deleted file mode 100644 index aead3c13..00000000 --- a/src/hooks/queries/useBlocksAtTimestamp.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import type { SupportedNetworks } from '@/utils/networks'; -import { getClient } from '@/utils/rpc'; -import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; - -/** - * Estimates and fetches blocks at a specific timestamp across multiple chains. - * - * Two-step process: - * 1. Client-side estimation using estimateBlockAtTimestamp (instant) - * 2. On-chain verification by fetching actual block timestamps - * - * This ensures accurate time ranges for historical queries while providing - * fast initial estimates. - * - * Cache behavior: - * - staleTime: 5 minutes (historical blocks don't change) - * - gcTime: 10 minutes - * - Only runs when currentBlocks are available - * - * @param timestamp - Target timestamp (seconds since epoch) - * @param chainIds - Array of chain IDs to estimate blocks for - * @param currentBlocks - Current block numbers from useCurrentBlocks - * @param customRpcUrls - Optional custom RPC URLs by chain ID - * @returns React Query result with Record - * - * @example - * ```tsx - * const oneDayAgo = Math.floor(Date.now() / 1000) - 86400; - * const { data: blocks } = useBlocksAtTimestamp( - * oneDayAgo, - * [1, 8453], - * currentBlocks, - * customRpcUrls - * ); - * // blocks = { - * // 1: { block: 12340000, timestamp: 1234567890 }, - * // 8453: { block: 9870000, timestamp: 1234567892 } - * // } - * ``` - */ -export const useBlocksAtTimestamp = ( - timestamp: number, - chainIds: SupportedNetworks[], - currentBlocks: Record | undefined, - customRpcUrls?: Record, -) => { - // Step 1: Client-side estimation (instant, no RPC calls) - const estimatedBlocks = useMemo(() => { - if (!currentBlocks) return {}; - - const blocks: Record = {}; - chainIds.forEach((chainId) => { - const currentBlock = currentBlocks[chainId]; - if (currentBlock) { - blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); - } - }); - - return blocks; - }, [timestamp, chainIds, currentBlocks]); - - // Step 2: Fetch actual blocks to verify timestamps - return useQuery({ - queryKey: ['blocks-at-timestamp', timestamp, chainIds.sort().join(',')], - queryFn: async () => { - const blockData: Record = {}; - - await Promise.all( - Object.entries(estimatedBlocks).map(async ([chainId, blockNum]) => { - try { - const client = getClient( - Number(chainId) as SupportedNetworks, - customRpcUrls?.[Number(chainId) as SupportedNetworks], - ); - const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); - blockData[Number(chainId)] = { - block: blockNum, - timestamp: Number(block.timestamp), - }; - } catch (error) { - console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); - } - }), - ); - - return blockData; - }, - enabled: !!currentBlocks && Object.keys(estimatedBlocks).length > 0, - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, // 10 minutes - }); -}; diff --git a/src/hooks/queries/useCurrentBlocks.ts b/src/hooks/queries/useCurrentBlocks.ts deleted file mode 100644 index 4bf7fbb5..00000000 --- a/src/hooks/queries/useCurrentBlocks.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import type { SupportedNetworks } from '@/utils/networks'; -import { getClient } from '@/utils/rpc'; - -/** - * Fetches current block numbers for the specified chains. - * - * This hook provides real-time block numbers that can be used for: - * - Block estimation calculations - * - Snapshot queries at current state - * - Time-to-block conversions - * - * Cache behavior: - * - staleTime: 30 seconds (blocks change frequently) - * - gcTime: 60 seconds - * - Refetches automatically when chains change - * - * @param chainIds - Array of chain IDs to fetch blocks for - * @param customRpcUrls - Optional custom RPC URLs by chain ID - * @returns React Query result with Record - * - * @example - * ```tsx - * const { data: currentBlocks } = useCurrentBlocks([1, 8453], customRpcUrls); - * // currentBlocks = { 1: 12345678, 8453: 9876543 } - * ``` - */ -export const useCurrentBlocks = ( - chainIds: SupportedNetworks[], - customRpcUrls?: Record, -) => { - return useQuery({ - queryKey: ['current-blocks', chainIds.sort().join(',')], - queryFn: async () => { - const blocks: Record = {}; - - await Promise.all( - chainIds.map(async (chainId) => { - try { - const client = getClient(chainId, customRpcUrls?.[chainId]); - const blockNumber = await client.getBlockNumber(); - blocks[chainId] = Number(blockNumber); - } catch (error) { - console.error(`Failed to get current block for chain ${chainId}:`, error); - } - }), - ); - - return blocks; - }, - enabled: chainIds.length > 0, - staleTime: 30_000, // 30 seconds - gcTime: 60_000, // 1 minute - }); -}; diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index 862be16e..676f74e1 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -7,7 +7,7 @@ type UseUserTransactionsQueryOptions = { /** * When true, automatically paginates to fetch ALL transactions. * Use for report generation when complete accuracy is needed. - * When false (default), fetches up to 1000 transactions (single page). + * When false (default), fetches up to 1000 transactions and returns isTruncated flag. * Use for summary pages when speed is prioritized. */ paginate?: boolean; @@ -15,7 +15,10 @@ type UseUserTransactionsQueryOptions = { pageSize?: number; }; -type TransactionQueryResult = TransactionResponse; +type TransactionQueryResult = TransactionResponse & { + /** Indicates if data was truncated due to pagination limits */ + isTruncated: boolean; +}; /** * Fetches user transactions from Morpho API or Subgraph using React Query. @@ -34,7 +37,7 @@ type TransactionQueryResult = TransactionResponse; * * @example * ```tsx - * // Summary page (fast, single page) + * // Summary page (fast, may be truncated) * const { data, isLoading } = useUserTransactionsQuery({ * filters: { * userAddress: ['0x...'], @@ -42,8 +45,11 @@ type TransactionQueryResult = TransactionResponse; * }, * paginate: false, * }); + * if (data?.isTruncated) { + * // Show warning to user + * } * - * // Report page (complete data with auto-pagination) + * // Report page (complete data) * const { data } = useUserTransactionsQuery({ * filters: { * userAddress: ['0x...'], @@ -74,10 +80,20 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption queryFn: async () => { if (!paginate) { // Simple case: fetch once with limit - return await fetchUserTransactions({ + const response = await fetchUserTransactions({ ...filters, first: pageSize, }); + + // Check if data was truncated + // Since we're now filtering at GraphQL level, if we get exactly pageSize items, + // there might be more available + const isTruncated = response.items.length >= pageSize; + + return { + ...response, + isTruncated, + }; } // Pagination mode: fetch all data across multiple requests @@ -112,6 +128,7 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption countTotal: allItems.length, }, error: null, + isTruncated: false, // We fetched everything }; }, enabled: enabled && filters.userAddress.length > 0, diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index a61485fe..201d22c3 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -38,8 +38,7 @@ export const positionKeys = { snapshot: (marketKey: string, userAddress: string, chainId: number) => [...positionKeys.all, 'snapshot', marketKey, userAddress, chainId] as const, // Key for the final enhanced position data, dependent on initialData result - // marketsCount triggers re-fetch when markets finish loading - enhanced: (user: string | undefined, initialData: InitialDataResponse | undefined, marketsCount: number) => + enhanced: (user: string | undefined, initialData: InitialDataResponse | undefined) => [ 'enhanced-positions', user, @@ -47,7 +46,6 @@ export const positionKeys = { .map((k) => `${k.marketUniqueKey.toLowerCase()}-${k.chainId}`) .sort() .join(','), - marketsCount, ] as const, }; @@ -69,8 +67,6 @@ const fetchSourceMarketKeys = async (user: string, chainIds?: SupportedNetworks[ try { console.log(`Attempting to fetch positions via Morpho API for network ${network}`); markets = await fetchMorphoUserPositionMarkets(user, network); - - console.log('Fetched market keys for network', network, markets.length) } catch (morphoError) { console.error(`Failed to fetch positions via Morpho API for network ${network}:`, morphoError); // Continue to Subgraph fallback @@ -144,7 +140,7 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? // console.log(`[Positions] Query 1: Final unique keys count: ${finalMarketKeys.length}`); return { finalMarketKeys }; }, - enabled: !!user, + enabled: !!user && allMarkets.length > 0, staleTime: 0, }); @@ -154,7 +150,7 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? isLoading: isLoadingEnhanced, isRefetching: isRefetchingEnhanced, } = useQuery({ - queryKey: positionKeys.enhanced(user, initialData, allMarkets.length), + queryKey: positionKeys.enhanced(user, initialData), queryFn: async () => { if (!initialData || !user) throw new Error('Assertion failed: initialData/user should be defined here.'); diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 2586073e..696edd59 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { Address } from 'viem'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; @@ -9,8 +10,6 @@ import { useUserTransactionsQuery } from './queries/useUserTransactionsQuery'; import { usePositionsWithEarnings, getPeriodTimestamp } from './usePositionsWithEarnings'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -import { useCurrentBlocks } from './queries/useCurrentBlocks'; -import { useBlocksAtTimestamp } from './queries/useBlocksAtTimestamp'; // Re-export EarningsPeriod for backward compatibility export type { EarningsPeriod } from '@/stores/usePositionsFilters'; @@ -27,33 +26,92 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP [chainIds, positions], ); - // Use extracted block hooks for cleaner code - const { data: currentBlocks } = useCurrentBlocks(uniqueChainIds, customRpcUrls); + const { data: currentBlocks } = useQuery({ + queryKey: ['current-blocks', uniqueChainIds], + queryFn: async () => { + const blocks: Record = {}; + await Promise.all( + uniqueChainIds.map(async (chainId) => { + try { + const client = getClient(chainId, customRpcUrls[chainId]); + const blockNumber = await client.getBlockNumber(); + blocks[chainId] = Number(blockNumber); + } catch (error) { + console.error(`Failed to get current block for chain ${chainId}:`, error); + } + }), + ); + return blocks; + }, + enabled: uniqueChainIds.length > 0, + staleTime: 30_000, + gcTime: 60_000, + }); + + const snapshotBlocks = useMemo(() => { + if (!currentBlocks) return {}; + + const timestamp = getPeriodTimestamp(period); + const blocks: Record = {}; + + uniqueChainIds.forEach((chainId) => { + const currentBlock = currentBlocks[chainId]; + if (currentBlock) { + blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); + } + }); + + return blocks; + }, [period, uniqueChainIds, currentBlocks]); - const periodTimestamp = useMemo(() => getPeriodTimestamp(period), [period]); - const { data: actualBlockData } = useBlocksAtTimestamp(periodTimestamp, uniqueChainIds, currentBlocks, customRpcUrls); + const { data: actualBlockData } = useQuery({ + queryKey: ['block-timestamps', snapshotBlocks], + queryFn: async () => { + const blockData: Record = {}; + + await Promise.all( + Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { + try { + const client = getClient(Number(chainId) as SupportedNetworks, customRpcUrls[Number(chainId) as SupportedNetworks]); + const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); + blockData[Number(chainId)] = { + block: blockNum, + timestamp: Number(block.timestamp), + }; + } catch (error) { + console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); + } + }), + ); + + return blockData; + }, + enabled: Object.keys(snapshotBlocks).length > 0, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); const endTimestamp = useMemo(() => Math.floor(Date.now() / 1000), []); const { data: txData, isLoading: isLoadingTransactions } = useUserTransactionsQuery({ filters: { userAddress: user ? [user] : [], - marketUniqueKeys: positions?.map((p) => p.market.uniqueKey), + marketUniqueKeys: positions?.map((p) => p.market.uniqueKey) ?? [], chainIds: uniqueChainIds, }, - paginate: true, // Always fetch all transactions for accuracy + paginate: false, enabled: !!positions && !!user, }); const { data: allSnapshots, isLoading: isLoadingSnapshots } = useQuery({ - queryKey: ['all-position-snapshots', actualBlockData, user, positions?.map((p) => p.market.uniqueKey)], + queryKey: ['all-position-snapshots', snapshotBlocks, user, positions?.map((p) => p.market.uniqueKey)], queryFn: async () => { - if (!positions || !user || !actualBlockData) return {}; + if (!positions || !user) return {}; const snapshotsByChain: Record> = {}; await Promise.all( - Object.entries(actualBlockData).map(async ([chainId, blockData]) => { + Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { const chainIdNum = Number(chainId); const chainPositions = positions.filter((p) => p.market.morphoBlue.chain.id === chainIdNum); @@ -62,7 +120,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP const client = getClient(chainIdNum as SupportedNetworks, customRpcUrls[chainIdNum as SupportedNetworks]); const marketIds = chainPositions.map((p) => p.market.uniqueKey); - const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockData.block, client); + const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockNum, client); snapshotsByChain[chainIdNum] = snapshots; }), @@ -70,7 +128,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP return snapshotsByChain; }, - enabled: !!positions && !!user && !!actualBlockData && Object.keys(actualBlockData).length > 0, + enabled: !!positions && !!user && Object.keys(snapshotBlocks).length > 0, staleTime: 5 * 60 * 1000, gcTime: 10 * 60 * 1000, }); @@ -123,6 +181,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP isPositionsLoading: positionsLoading, isEarningsLoading, isRefetching, + isTruncated: txData?.isTruncated ?? false, error: positionsError, refetch, loadingStates, From 2c4b37ba439f358d1670acfd1954f1a95d182777 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 18:03:30 +0800 Subject: [PATCH 04/11] fix: history query --- src/data-sources/morpho-api/transactions.ts | 10 ++++---- src/data-sources/subgraph/transactions.ts | 6 +++-- .../components/asset-selector.tsx | 2 -- .../positions-report-view.tsx | 17 +++---------- src/graphql/morpho-subgraph-queries.ts | 24 +++++++++++-------- src/hooks/usePositionReport.ts | 6 ++--- src/hooks/useUserPositions.ts | 2 +- 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 1dc0c6be..674a4241 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -19,9 +19,9 @@ export const fetchMorphoTransactions = async (filters: TransactionFilters): Prom chainId_in: filters.chainIds ?? [SupportedNetworks.Base, SupportedNetworks.Mainnet], }; - // if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { - // whereClause.marketUniqueKey_in = filters.marketUniqueKeys; - // } + if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { + whereClause.marketUniqueKey_in = filters.marketUniqueKeys; + } if (filters.timestampGte !== undefined && filters.timestampGte !== null) { whereClause.timestamp_gte = filters.timestampGte; } @@ -32,11 +32,11 @@ export const fetchMorphoTransactions = async (filters: TransactionFilters): Prom whereClause.hash = filters.hash; } if (filters.assetIds && filters.assetIds.length > 0) { - whereClause.assetId_in = filters.assetIds; + whereClause.assetAddress_in = filters.assetIds; } try { - console.log('try', whereClause) + console.log('try', whereClause); const result = await morphoGraphqlFetcher(userTransactionsQuery, { where: whereClause, diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts index 7ea5f6ab..5091ed43 100644 --- a/src/data-sources/subgraph/transactions.ts +++ b/src/data-sources/subgraph/transactions.ts @@ -1,4 +1,4 @@ -import { subgraphUserTransactionsQuery } from '@/graphql/morpho-subgraph-queries'; +import { getSubgraphUserTransactionsQuery } from '@/graphql/morpho-subgraph-queries'; import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/fetchUserTransactions'; import type { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; @@ -175,8 +175,10 @@ export const fetchSubgraphTransactions = async (filters: TransactionFilters, net variables.timestamp_lte = filters.timestampLte; } + const useMarketFilter = variables.market_in !== undefined; + const requestBody = { - query: subgraphUserTransactionsQuery, + query: getSubgraphUserTransactionsQuery(useMarketFilter), variables: variables, }; diff --git a/src/features/positions-report/components/asset-selector.tsx b/src/features/positions-report/components/asset-selector.tsx index a17b5ae1..9be12004 100644 --- a/src/features/positions-report/components/asset-selector.tsx +++ b/src/features/positions-report/components/asset-selector.tsx @@ -23,8 +23,6 @@ export function AssetSelector({ selectedAsset, assets, onSelect }: AssetSelector const [query, setQuery] = useState(''); const dropdownRef = useRef(null); - console.log('query', query); - const filteredAssets = assets.filter((asset) => asset.symbol.toLowerCase().includes(query.toLowerCase())); // Close dropdown when clicking outside diff --git a/src/features/positions-report/positions-report-view.tsx b/src/features/positions-report/positions-report-view.tsx index 134ea1ff..944c9ca3 100644 --- a/src/features/positions-report/positions-report-view.tsx +++ b/src/features/positions-report/positions-report-view.tsx @@ -26,7 +26,7 @@ type ReportState = { export default function ReportContent({ account }: { account: Address }) { // Fetch ALL positions including closed ones (onlySupplied: false) // This ensures report includes markets that were active during the selected period - const { loading, data: positions } = useUserPositions(account, false); + const { loading, data: positions } = useUserPositions(account, true); const [selectedAsset, setSelectedAsset] = useState(null); // Get today's date and 2 months ago @@ -55,17 +55,6 @@ export default function ReportContent({ account }: { account: Address }) { // Calculate maximum allowed date (today) const maxDate = useMemo(() => now(getLocalTimeZone()), []); - // Check if current inputs match the report state - const isReportCurrent = useMemo(() => { - if (!reportState || !selectedAsset) return false; - return ( - reportState.asset.address === selectedAsset.address && - reportState.asset.chainId === selectedAsset.chainId && - reportState.startDate.compare(startDate) === 0 && - reportState.endDate.compare(endDate) === 0 - ); - }, [reportState, selectedAsset, startDate, endDate]); - // Reset report when inputs change useEffect(() => { if (!reportState || !selectedAsset) return; @@ -103,7 +92,7 @@ export default function ReportContent({ account }: { account: Address }) { // Generate report const handleGenerateReport = async () => { - if (!selectedAsset || isGenerating || isReportCurrent) return; + if (!selectedAsset || isGenerating) return; setIsGenerating(true); try { @@ -235,7 +224,7 @@ export default function ReportContent({ account }: { account: Address }) { onClick={() => { void handleGenerateReport(); }} - disabled={!selectedAsset || isGenerating || isReportCurrent || !!startDateError || !!endDateError} + disabled={!selectedAsset || isGenerating || !!startDateError || !!endDateError} className="inline-flex h-14 min-w-[120px] items-center gap-2" variant="primary" > diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 697f6a62..cf753d95 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -293,15 +293,18 @@ export const subgraphUserMarketPositionQuery = ` `; // --- End Query --- -// Note: The exact field names might need adjustment based on the specific Subgraph schema. -export const subgraphUserTransactionsQuery = ` +export const getSubgraphUserTransactionsQuery = (useMarketFilter: boolean) => { + // only append this in where if marketIn is defined + const additionalQuery = useMarketFilter ? 'market_in: $market_in' : ''; + + return ` query GetUserTransactions( $userId: ID! $first: Int! $skip: Int! - $timestamp_gt: BigInt! # Always filter from timestamp 0 - $timestamp_lt: BigInt! # Always filter up to current time - $market_in: [Bytes!] # Optional market filter + $timestamp_gt: BigInt! + $timestamp_lt: BigInt! + ${useMarketFilter ? '$market_in: [Bytes!]}' : ''} ) { account(id: $userId) { deposits( @@ -312,7 +315,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt - market_in: $market_in + ${additionalQuery} } ) { id @@ -333,7 +336,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt - market_in: $market_in + ${additionalQuery} } ) { id @@ -354,7 +357,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt - market_in: $market_in + ${additionalQuery} } ) { id @@ -374,7 +377,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt - market_in: $market_in + ${additionalQuery} } ) { id @@ -394,7 +397,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt - market_in: $market_in + ${additionalQuery} } ) { id @@ -408,6 +411,7 @@ export const subgraphUserTransactionsQuery = ` } } `; +}; export const marketPositionsQuery = ` query getMarketPositions($market: String!, $minShares: BigInt!, $first: Int!, $skip: Int!) { diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 6c92129e..c84d6528 100644 --- a/src/hooks/usePositionReport.ts +++ b/src/hooks/usePositionReport.ts @@ -85,9 +85,9 @@ export const usePositionReport = ( const transactionResult = await fetchUserTransactions({ userAddress: [account], chainIds: [selectedAsset.chainId], - timestampGte: actualStartTimestamp, // ✅ Use actual timestamp from block - timestampLte: actualEndTimestamp, // ✅ Use actual timestamp from block - assetIds: [selectedAsset.address], // Query by asset to find ALL markets + timestampGte: actualStartTimestamp, + timestampLte: actualEndTimestamp, + assetIds: [selectedAsset.address], first: PAGE_SIZE, skip, }); diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 201d22c3..34196033 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -166,7 +166,7 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? marketsByChain.set(marketInfo.chainId, existing); }); - console.log('All markets by chain', marketsByChain) + console.log('All markets by chain', marketsByChain); // Build market data map from allMarkets context (no need to fetch individually) const marketDataMap = new Map(); From 5ea3fb9ea969320340b68690e1dc1decc32a0074 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 18:06:43 +0800 Subject: [PATCH 05/11] chore: remove isTruncated --- .../supplied-morpho-blue-grouped-table.tsx | 15 ------------- src/features/positions/positions-view.tsx | 4 ---- src/hooks/queries/useUserTransactionsQuery.ts | 22 +------------------ src/hooks/useUserPositionsSummaryData.ts | 1 - 4 files changed, 1 insertion(+), 41 deletions(-) diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index b617160d..47e071d2 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -101,8 +101,6 @@ type SuppliedMorphoBlueGroupedTableProps = { isLoadingEarnings?: boolean; earningsPeriod: EarningsPeriod; setEarningsPeriod: (period: EarningsPeriod) => void; - chainBlockData?: Record; - isTruncated?: boolean; }; export function SuppliedMorphoBlueGroupedTable({ @@ -113,8 +111,6 @@ export function SuppliedMorphoBlueGroupedTable({ account, earningsPeriod, setEarningsPeriod, - chainBlockData, - isTruncated, }: SuppliedMorphoBlueGroupedTableProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); @@ -309,17 +305,6 @@ export function SuppliedMorphoBlueGroupedTable({ margin={3} /> - ) : isTruncated ? ( - - } - > - - - ) : ( {(() => { diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index 19f2f488..edcf8b4a 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -34,8 +34,6 @@ export default function Positions() { positions: marketPositions, refetch, loadingStates, - isTruncated, - actualBlockData, } = useUserPositionsSummaryData(account, earningsPeriod); // Fetch user's auto vaults @@ -112,8 +110,6 @@ export default function Positions() { isLoadingEarnings={isEarningsLoading} earningsPeriod={earningsPeriod} setEarningsPeriod={setEarningsPeriod} - chainBlockData={actualBlockData} - isTruncated={isTruncated} /> )} diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index 676f74e1..8df5f631 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -7,7 +7,6 @@ type UseUserTransactionsQueryOptions = { /** * When true, automatically paginates to fetch ALL transactions. * Use for report generation when complete accuracy is needed. - * When false (default), fetches up to 1000 transactions and returns isTruncated flag. * Use for summary pages when speed is prioritized. */ paginate?: boolean; @@ -15,11 +14,6 @@ type UseUserTransactionsQueryOptions = { pageSize?: number; }; -type TransactionQueryResult = TransactionResponse & { - /** Indicates if data was truncated due to pagination limits */ - isTruncated: boolean; -}; - /** * Fetches user transactions from Morpho API or Subgraph using React Query. * @@ -45,9 +39,6 @@ type TransactionQueryResult = TransactionResponse & { * }, * paginate: false, * }); - * if (data?.isTruncated) { - * // Show warning to user - * } * * // Report page (complete data) * const { data } = useUserTransactionsQuery({ @@ -80,20 +71,10 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption queryFn: async () => { if (!paginate) { // Simple case: fetch once with limit - const response = await fetchUserTransactions({ + return await fetchUserTransactions({ ...filters, first: pageSize, }); - - // Check if data was truncated - // Since we're now filtering at GraphQL level, if we get exactly pageSize items, - // there might be more available - const isTruncated = response.items.length >= pageSize; - - return { - ...response, - isTruncated, - }; } // Pagination mode: fetch all data across multiple requests @@ -128,7 +109,6 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption countTotal: allItems.length, }, error: null, - isTruncated: false, // We fetched everything }; }, enabled: enabled && filters.userAddress.length > 0, diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 696edd59..b99b9f79 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -181,7 +181,6 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP isPositionsLoading: positionsLoading, isEarningsLoading, isRefetching, - isTruncated: txData?.isTruncated ?? false, error: positionsError, refetch, loadingStates, From 9b6d0ff6b8933fe56132da11153b28e5e4e928c5 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 18:29:19 +0800 Subject: [PATCH 06/11] misc: cleanup --- .../supplied-morpho-blue-grouped-table.tsx | 45 +++---- src/features/positions/positions-view.tsx | 77 ++---------- src/hooks/queries/useBlockTimestamps.ts | 40 ++++++ src/hooks/queries/useCurrentBlocks.ts | 31 +++++ src/hooks/queries/usePositionSnapshots.ts | 48 ++++++++ .../queries/usePositionSnapshotsQuery.ts | 60 --------- src/hooks/queries/useUserTransactionsQuery.ts | 26 +--- src/hooks/useUserPositionsSummaryData.ts | 95 ++------------- src/utils/blockFinder.ts | 114 ------------------ 9 files changed, 159 insertions(+), 377 deletions(-) create mode 100644 src/hooks/queries/useBlockTimestamps.ts create mode 100644 src/hooks/queries/useCurrentBlocks.ts create mode 100644 src/hooks/queries/usePositionSnapshots.ts delete mode 100644 src/hooks/queries/usePositionSnapshotsQuery.ts delete mode 100644 src/utils/blockFinder.ts diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 47e071d2..fdafb935 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -19,16 +19,17 @@ import { TableContainerWithHeader } from '@/components/common/table-container-wi import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; import { useDisclosure } from '@/hooks/useDisclosure'; import { usePositionsPreferences } from '@/stores/usePositionsPreferences'; +import { usePositionsFilters } from '@/stores/usePositionsFilters'; import { useAppSettings } from '@/stores/useAppSettings'; import { computeMarketWarnings } from '@/hooks/useMarketWarnings'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useStyledToast } from '@/hooks/useStyledToast'; -import type { EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; +import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { formatReadable, formatBalance } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; import { getGroupedEarnings, groupPositionsByLoanAsset, processCollaterals } from '@/utils/positions'; import { convertApyToApr } from '@/utils/rateMath'; -import { type GroupedPosition, type MarketPositionWithEarnings, type WarningWithDetail, WarningCategory } from '@/utils/types'; +import { type GroupedPosition, type WarningWithDetail, WarningCategory } from '@/utils/types'; import { RiskIndicator } from '@/features/markets/components/risk-indicator'; import { PositionActionsDropdown } from './position-actions-dropdown'; import { RebalanceModal } from './rebalance/rebalance-modal'; @@ -95,27 +96,17 @@ function AggregatedRiskIndicators({ groupedPosition }: { groupedPosition: Groupe type SuppliedMorphoBlueGroupedTableProps = { account: string; - marketPositions: MarketPositionWithEarnings[]; - refetch: (onSuccess?: () => void) => void; - isRefetching: boolean; - isLoadingEarnings?: boolean; - earningsPeriod: EarningsPeriod; - setEarningsPeriod: (period: EarningsPeriod) => void; }; -export function SuppliedMorphoBlueGroupedTable({ - marketPositions, - refetch, - isRefetching, - isLoadingEarnings, - account, - earningsPeriod, - setEarningsPeriod, -}: SuppliedMorphoBlueGroupedTableProps) { +export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGroupedTableProps) { + const period = usePositionsFilters((s) => s.period); + const setPeriod = usePositionsFilters((s) => s.setPeriod); + + const { positions: marketPositions, refetch, isRefetching, isEarningsLoading } = useUserPositionsSummaryData(account, period); + const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); const [selectedGroupedPosition, setSelectedGroupedPosition] = useState(null); - // Positions preferences from Zustand store const { showCollateralExposure, setShowCollateralExposure } = usePositionsPreferences(); const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onOpenChange: onSettingsOpenChange } = useDisclosure(); const { address } = useConnection(); @@ -129,11 +120,11 @@ export function SuppliedMorphoBlueGroupedTable({ return account === address; }, [account, address]); - const periodLabels: Record = { + const periodLabels = { day: '1D', week: '7D', month: '30D', - }; + } as const; const groupedPositions = useMemo(() => groupPositionsByLoanAsset(marketPositions), [marketPositions]); @@ -228,7 +219,7 @@ export function SuppliedMorphoBlueGroupedTable({ {rateLabel} (now) - Interest Accrued ({earningsPeriod}) + Interest Accrued ({period}) {formatReadable((isAprDisplay ? convertApyToApr(avgApy) : avgApy) * 100)}% - +
- {isLoadingEarnings ? ( + {isEarningsLoading ? (
- {Object.entries(periodLabels).map(([period, label]) => ( + {Object.entries(periodLabels).map(([periodKey, label]) => ( diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index edcf8b4a..0ff4c7c4 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -1,19 +1,13 @@ 'use client'; -import { useMemo, useState } from 'react'; import { useParams } from 'next/navigation'; -import { Tooltip } from '@/components/ui/tooltip'; -import { IoRefreshOutline } from 'react-icons/io5'; -import { toast } from 'react-toastify'; import type { Address } from 'viem'; import { AccountIdentity } from '@/components/shared/account-identity'; -import { Button } from '@/components/ui/button'; import Header from '@/components/layout/header/Header'; import EmptyScreen from '@/components/status/empty-screen'; import LoadingScreen from '@/components/status/loading-screen'; -import { TooltipContent } from '@/components/shared/tooltip-content'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; +import useUserPositionsSummaryData from '@/hooks/useUserPositionsSummaryData'; import { usePortfolioValue } from '@/hooks/usePortfolioValue'; import { useUserVaultsV2Query } from '@/hooks/queries/useUserVaultsV2Query'; import { SuppliedMorphoBlueGroupedTable } from './components/supplied-morpho-blue-grouped-table'; @@ -21,20 +15,11 @@ import { PortfolioValueBadge } from './components/portfolio-value-badge'; import { UserVaultsTable } from './components/user-vaults-table'; export default function Positions() { - const [earningsPeriod, setEarningsPeriod] = useState('day'); - const { account } = useParams<{ account: string }>(); const { loading: isMarketsLoading } = useProcessedMarkets(); - const { - isPositionsLoading, - isEarningsLoading, - isRefetching, - positions: marketPositions, - refetch, - loadingStates, - } = useUserPositionsSummaryData(account, earningsPeriod); + const { isPositionsLoading, positions: marketPositions } = useUserPositionsSummaryData(account, 'day'); // Fetch user's auto vaults const { @@ -48,23 +33,12 @@ export default function Positions() { 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.snapshots) return 'Loading historical snapshots...'; - if (loadingStates.transactions) return 'Loading transaction history...'; - return 'Loading...'; - }, [isMarketsLoading, loadingStates]); + const loadingMessage = isMarketsLoading ? 'Loading markets...' : 'Loading user positions...'; const hasSuppliedMarkets = marketPositions && marketPositions.length > 0; const hasVaults = vaults && vaults.length > 0; const showEmpty = !loading && !isVaultsLoading && !hasSuppliedMarkets && !hasVaults; - const handleRefetch = () => { - void refetch(() => toast.info('Data refreshed', { icon: 🚀 })); - }; - return (
@@ -101,17 +75,7 @@ export default function Positions() { )} {/* Morpho Blue Positions Section */} - {!loading && hasSuppliedMarkets && ( - void refetch()} - isRefetching={isRefetching} - isLoadingEarnings={isEarningsLoading} - earningsPeriod={earningsPeriod} - setEarningsPeriod={setEarningsPeriod} - /> - )} + {!loading && hasSuppliedMarkets && } {/* Auto Vaults Section (progressive loading) */} {isVaultsLoading && !loading && ( @@ -131,35 +95,10 @@ export default function Positions() { {/* Empty state (only if both finished loading and both empty) */} {showEmpty && ( -
-
- - } - > - - - - -
-
- -
-
+ )}
diff --git a/src/hooks/queries/useBlockTimestamps.ts b/src/hooks/queries/useBlockTimestamps.ts new file mode 100644 index 00000000..6cb34c3c --- /dev/null +++ b/src/hooks/queries/useBlockTimestamps.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; + +/** + * + * @param snapshotBlocks { chainId: blockNumber } + */ +export const useBlockTimestamps = (snapshotBlocks: Record) => { + const { customRpcUrls } = useCustomRpcContext(); + + return useQuery({ + queryKey: ['block-timestamps', snapshotBlocks], + queryFn: async () => { + const blockData: Record = {}; + + await Promise.all( + Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { + try { + const client = getClient(Number(chainId) as SupportedNetworks, customRpcUrls[Number(chainId) as SupportedNetworks]); + const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); + blockData[Number(chainId)] = { + block: blockNum, + timestamp: Number(block.timestamp), + }; + } catch (error) { + console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); + } + }), + ); + + return blockData; + }, + enabled: Object.keys(snapshotBlocks).length > 0, + staleTime: Number.POSITIVE_INFINITY, + gcTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/queries/useCurrentBlocks.ts b/src/hooks/queries/useCurrentBlocks.ts new file mode 100644 index 00000000..3aa40440 --- /dev/null +++ b/src/hooks/queries/useCurrentBlocks.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; + +export const useCurrentBlocks = (chainIds: SupportedNetworks[]) => { + const { customRpcUrls } = useCustomRpcContext(); + + return useQuery({ + queryKey: ['current-blocks', chainIds], + queryFn: async () => { + const blocks: Record = {}; + await Promise.all( + chainIds.map(async (chainId) => { + try { + const client = getClient(chainId, customRpcUrls[chainId]); + const blockNumber = await client.getBlockNumber(); + blocks[chainId] = Number(blockNumber); + } catch (error) { + console.error(`Failed to get current block for chain ${chainId}:`, error); + } + }), + ); + return blocks; + }, + enabled: chainIds.length > 0, + staleTime: 2 * 60 * 1000, + gcTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/queries/usePositionSnapshots.ts b/src/hooks/queries/usePositionSnapshots.ts new file mode 100644 index 00000000..e22a78e7 --- /dev/null +++ b/src/hooks/queries/usePositionSnapshots.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +import type { Address } from 'viem'; +import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; +import type { SupportedNetworks } from '@/utils/networks'; +import { getClient } from '@/utils/rpc'; +import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; +import type { MarketPosition } from '@/utils/types'; + +type UsePositionSnapshotsOptions = { + positions: MarketPosition[] | undefined; + user: string | undefined; + snapshotBlocks: Record; +}; + +export const usePositionSnapshots = ({ positions, user, snapshotBlocks }: UsePositionSnapshotsOptions) => { + const { customRpcUrls } = useCustomRpcContext(); + + return useQuery({ + queryKey: ['all-position-snapshots', snapshotBlocks, user, positions?.map((p) => p.market.uniqueKey)], + queryFn: async () => { + if (!positions || !user) return {}; + + const snapshotsByChain: Record> = {}; + + await Promise.all( + Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { + const chainIdNum = Number(chainId); + const chainPositions = positions.filter((p) => p.market.morphoBlue.chain.id === chainIdNum); + + if (chainPositions.length === 0) return; + + const client = getClient(chainIdNum as SupportedNetworks, customRpcUrls[chainIdNum as SupportedNetworks]); + const marketIds = chainPositions.map((p) => p.market.uniqueKey); + + const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockNum, client); + + snapshotsByChain[chainIdNum] = snapshots; + }), + ); + + return snapshotsByChain; + }, + enabled: !!positions && !!user && Object.keys(snapshotBlocks).length > 0, + staleTime: 0, + gcTime: 10 * 60 * 1000, + refetchOnWindowFocus: false, + }); +}; diff --git a/src/hooks/queries/usePositionSnapshotsQuery.ts b/src/hooks/queries/usePositionSnapshotsQuery.ts deleted file mode 100644 index ac0f2d0c..00000000 --- a/src/hooks/queries/usePositionSnapshotsQuery.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import type { Address } from 'viem'; -import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -import type { SupportedNetworks } from '@/utils/networks'; -import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; -import { getClient } from '@/utils/rpc'; - -type PositionSnapshotsQueryOptions = { - /** User address to fetch snapshots for */ - user: Address; - /** Chain ID where positions exist */ - chainId: SupportedNetworks; - /** Market unique keys to fetch snapshots for */ - marketIds: string[]; - /** Block number to fetch snapshots at */ - blockNumber: number; - /** Whether to enable the query (default: true) */ - enabled?: boolean; -}; - -/** - * Fetches position snapshots at a specific block number using React Query. - * - * This hook fetches historical balances for user positions at a specific block, - * which is essential for calculating earnings over a time period. - * - * Cache behavior: - * - staleTime: 5 minutes (historical snapshots don't change) - * - Only runs when marketIds are provided and blockNumber > 0 - * - * @example - * ```tsx - * const { data: snapshots, isLoading } = usePositionSnapshotsQuery({ - * user: '0x...' as Address, - * chainId: SupportedNetworks.Mainnet, - * marketIds: ['market1', 'market2'], - * blockNumber: 19000000, - * }); - * - * const snapshot = snapshots?.get('market1'); - * const pastBalance = snapshot ? BigInt(snapshot.supplyAssets) : 0n; - * ``` - */ -export const usePositionSnapshotsQuery = (options: PositionSnapshotsQueryOptions) => { - const { user, chainId, marketIds, blockNumber, enabled = true } = options; - const { customRpcUrls } = useCustomRpcContext(); - - return useQuery, Error>({ - queryKey: ['position-snapshots', user, chainId, marketIds, blockNumber], - queryFn: async () => { - const client = getClient(chainId, customRpcUrls[chainId]); - - const snapshots = await fetchPositionsSnapshots(marketIds, user, chainId, blockNumber, client); - - return snapshots; - }, - enabled: enabled && marketIds.length > 0 && blockNumber > 0, - staleTime: 5 * 60 * 1000, // 5 minutes - historical snapshots are immutable - }); -}; diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index 8df5f631..6d5badcb 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -28,32 +28,12 @@ type UseUserTransactionsQueryOptions = { * - staleTime: 30 seconds (transactions change moderately frequently) * - Refetch on window focus: enabled * - Only runs when userAddress is provided - * - * @example - * ```tsx - * // Summary page (fast, may be truncated) - * const { data, isLoading } = useUserTransactionsQuery({ - * filters: { - * userAddress: ['0x...'], - * timestampGte: oneDayAgo, - * }, - * paginate: false, - * }); - * - * // Report page (complete data) - * const { data } = useUserTransactionsQuery({ - * filters: { - * userAddress: ['0x...'], - * assetIds: ['0xUSDC...'], - * }, - * paginate: true, - * }); * ``` */ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOptions) => { const { filters, enabled = true, paginate = false, pageSize = 1000 } = options; - return useQuery({ + return useQuery({ queryKey: [ 'user-transactions', filters.userAddress, @@ -112,7 +92,7 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption }; }, enabled: enabled && filters.userAddress.length > 0, - staleTime: 30_000, // 30 seconds - transactions change moderately frequently - refetchOnWindowFocus: true, + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, }); }; diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index b99b9f79..fb550767 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,24 +1,20 @@ import { useMemo } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { Address } from 'viem'; +import { useQueryClient } from '@tanstack/react-query'; import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import type { SupportedNetworks } from '@/utils/networks'; -import { getClient } from '@/utils/rpc'; -import { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; import useUserPositions, { positionKeys } from './useUserPositions'; +import { useCurrentBlocks } from './queries/useCurrentBlocks'; +import { useBlockTimestamps } from './queries/useBlockTimestamps'; +import { usePositionSnapshots } from './queries/usePositionSnapshots'; import { useUserTransactionsQuery } from './queries/useUserTransactionsQuery'; import { usePositionsWithEarnings, getPeriodTimestamp } from './usePositionsWithEarnings'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; -import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -// Re-export EarningsPeriod for backward compatibility export type { EarningsPeriod } from '@/stores/usePositionsFilters'; const useUserPositionsSummaryData = (user: string | undefined, period: EarningsPeriod = 'day', chainIds?: SupportedNetworks[]) => { const queryClient = useQueryClient(); - const { customRpcUrls } = useCustomRpcContext(); - // Only fetch opened positions const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, false, chainIds); const uniqueChainIds = useMemo( @@ -26,27 +22,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP [chainIds, positions], ); - const { data: currentBlocks } = useQuery({ - queryKey: ['current-blocks', uniqueChainIds], - queryFn: async () => { - const blocks: Record = {}; - await Promise.all( - uniqueChainIds.map(async (chainId) => { - try { - const client = getClient(chainId, customRpcUrls[chainId]); - const blockNumber = await client.getBlockNumber(); - blocks[chainId] = Number(blockNumber); - } catch (error) { - console.error(`Failed to get current block for chain ${chainId}:`, error); - } - }), - ); - return blocks; - }, - enabled: uniqueChainIds.length > 0, - staleTime: 30_000, - gcTime: 60_000, - }); + const { data: currentBlocks } = useCurrentBlocks(uniqueChainIds); const snapshotBlocks = useMemo(() => { if (!currentBlocks) return {}; @@ -64,32 +40,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP return blocks; }, [period, uniqueChainIds, currentBlocks]); - const { data: actualBlockData } = useQuery({ - queryKey: ['block-timestamps', snapshotBlocks], - queryFn: async () => { - const blockData: Record = {}; - - await Promise.all( - Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { - try { - const client = getClient(Number(chainId) as SupportedNetworks, customRpcUrls[Number(chainId) as SupportedNetworks]); - const block = await client.getBlock({ blockNumber: BigInt(blockNum) }); - blockData[Number(chainId)] = { - block: blockNum, - timestamp: Number(block.timestamp), - }; - } catch (error) { - console.error(`Failed to get block ${blockNum} on chain ${chainId}:`, error); - } - }), - ); - - return blockData; - }, - enabled: Object.keys(snapshotBlocks).length > 0, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, - }); + const { data: actualBlockData } = useBlockTimestamps(snapshotBlocks); const endTimestamp = useMemo(() => Math.floor(Date.now() / 1000), []); @@ -99,38 +50,14 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP marketUniqueKeys: positions?.map((p) => p.market.uniqueKey) ?? [], chainIds: uniqueChainIds, }, - paginate: false, + paginate: true, enabled: !!positions && !!user, }); - const { data: allSnapshots, isLoading: isLoadingSnapshots } = useQuery({ - queryKey: ['all-position-snapshots', snapshotBlocks, user, positions?.map((p) => p.market.uniqueKey)], - queryFn: async () => { - if (!positions || !user) return {}; - - const snapshotsByChain: Record> = {}; - - await Promise.all( - Object.entries(snapshotBlocks).map(async ([chainId, blockNum]) => { - const chainIdNum = Number(chainId); - const chainPositions = positions.filter((p) => p.market.morphoBlue.chain.id === chainIdNum); - - if (chainPositions.length === 0) return; - - const client = getClient(chainIdNum as SupportedNetworks, customRpcUrls[chainIdNum as SupportedNetworks]); - const marketIds = chainPositions.map((p) => p.market.uniqueKey); - - const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainIdNum, blockNum, client); - - snapshotsByChain[chainIdNum] = snapshots; - }), - ); - - return snapshotsByChain; - }, - enabled: !!positions && !!user && Object.keys(snapshotBlocks).length > 0, - staleTime: 5 * 60 * 1000, - gcTime: 10 * 60 * 1000, + const { data: allSnapshots, isLoading: isLoadingSnapshots } = usePositionSnapshots({ + positions, + user, + snapshotBlocks, }); const positionsWithEarnings = usePositionsWithEarnings( diff --git a/src/utils/blockFinder.ts b/src/utils/blockFinder.ts deleted file mode 100644 index 4b82e1a2..00000000 --- a/src/utils/blockFinder.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { PublicClient } from 'viem'; -import { type SupportedNetworks, getBlocktime, getMaxBlockDelay } from './networks'; - -type BlockInfo = { - number: bigint; - timestamp: bigint; -}; - -export class SmartBlockFinder { - private readonly client: PublicClient; - - private readonly averageBlockTime: number; - - private readonly latestBlockDelay: number; - - private readonly TOLERANCE_SECONDS = 10; - - constructor(client: PublicClient, chainId: SupportedNetworks) { - this.client = client; - this.averageBlockTime = getBlocktime(chainId) || 12; - this.latestBlockDelay = getMaxBlockDelay(chainId) || 0; - } - - private async getBlock(blockNumber: bigint): Promise { - try { - const block = await this.client.getBlock({ blockNumber }); - return { - number: block.number, - timestamp: block.timestamp, - }; - } catch (_error) { - // await 1 second - await new Promise((resolve) => setTimeout(resolve, 1000)); - const block = await this.client.getBlock({ - blockNumber: blockNumber - 1n, - }); - return { - number: block.number, - timestamp: block.timestamp, - }; - } - } - - private isWithinTolerance(blockTimestamp: bigint, targetTimestamp: number): boolean { - const diff = Math.abs(Number(blockTimestamp) - targetTimestamp); - return diff <= this.TOLERANCE_SECONDS; - } - - async findNearestBlock(targetTimestamp: number): Promise { - // Get current block as upper bound, buffer with 3 blocks - - const lastestBlockNumber = (await this.client.getBlockNumber()) - BigInt(this.latestBlockDelay); - const latestBlock = await this.getBlock(lastestBlockNumber); - - const latestTimestamp = Number(latestBlock.timestamp); - - // If target is in the future, return latest block - if (targetTimestamp >= latestTimestamp) { - return latestBlock; - } - - // Calculate initial guess based on average block time - const timeDiff = latestTimestamp - targetTimestamp; - const estimatedBlocksBack = Math.max(Math.floor(timeDiff / this.averageBlockTime), 0); - - const initialGuess = latestBlock.number - BigInt(estimatedBlocksBack); - - // Get initial block - const initialBlock = await this.getBlock(initialGuess); - - // If within tolerance, return this block - if (this.isWithinTolerance(initialBlock.timestamp, targetTimestamp)) { - return initialBlock; - } - - // Binary search between genesis (or 0) and latest block - let left = 0n; - let right = latestBlock.number; - let closestBlock = initialBlock; - let closestDiff = Math.abs(Number(closestBlock.timestamp) - targetTimestamp); - - while (left <= right) { - const mid = left + (right - left) / 2n; - - if (mid > latestBlock.number) { - console.log('errorr .....'); - } - - const toQuery = mid > latestBlock.number ? latestBlock.number : mid; - const block = await this.getBlock(toQuery); - const blockTimestamp = Number(block.timestamp); - - // If within tolerance, return immediately - if (this.isWithinTolerance(block.timestamp, targetTimestamp)) { - return block; - } - - // Update closest block if this one is closer - const diff = Math.abs(blockTimestamp - targetTimestamp); - if (diff < closestDiff) { - closestBlock = block; - closestDiff = diff; - } - - if (blockTimestamp > targetTimestamp) { - right = mid - 1n; - } else { - left = mid + 1n; - } - } - - return closestBlock; - } -} From ee22df20d8a784c2c318ca0dd5b1b8ecb1338824 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 18:34:04 +0800 Subject: [PATCH 07/11] chore: clean --- src/data-sources/morpho-api/transactions.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 674a4241..8c8c3fc0 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -36,8 +36,6 @@ export const fetchMorphoTransactions = async (filters: TransactionFilters): Prom } try { - console.log('try', whereClause); - const result = await morphoGraphqlFetcher(userTransactionsQuery, { where: whereClause, first: filters.first ?? 1000, From 2fafeeb487820172726e5586d019461258324f10 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 18:37:17 +0800 Subject: [PATCH 08/11] chore: simplify --- src/hooks/usePositionsWithEarnings.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/hooks/usePositionsWithEarnings.ts b/src/hooks/usePositionsWithEarnings.ts index 91468047..d32fac03 100644 --- a/src/hooks/usePositionsWithEarnings.ts +++ b/src/hooks/usePositionsWithEarnings.ts @@ -7,17 +7,15 @@ import type { EarningsPeriod } from '@/stores/usePositionsFilters'; // Simple helper for the period timestamp calculation export const getPeriodTimestamp = (period: EarningsPeriod): number => { const now = Math.floor(Date.now() / 1000); - const DAY = 86_400; - switch (period) { case 'day': - return now - DAY; + return now - 86_400; case 'week': - return now - 7 * DAY; + return now - 7 * 86_400; case 'month': - return now - 30 * DAY; + return now - 30 * 86_400; default: - return now - DAY; + return now - 86_400; } }; From ffd23cec4a4e46de65e226b0f8e95f7397e08f93 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 19:01:32 +0800 Subject: [PATCH 09/11] feat: calculation --- .../supplied-morpho-blue-grouped-table.tsx | 71 ++++++++++--------- src/hooks/queries/useBlockTimestamps.ts | 2 +- src/hooks/useUserPositions.ts | 2 - src/utils/networks.ts | 6 +- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index fdafb935..0f9bfbd1 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -7,8 +7,6 @@ import { ReloadIcon } from '@radix-ui/react-icons'; import { GearIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; -import { BsQuestionCircle } from 'react-icons/bs'; -import { PiHandCoins } from 'react-icons/pi'; import { PulseLoader } from 'react-spinners'; import { useConnection } from 'wagmi'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; @@ -102,7 +100,13 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr const period = usePositionsFilters((s) => s.period); const setPeriod = usePositionsFilters((s) => s.setPeriod); - const { positions: marketPositions, refetch, isRefetching, isEarningsLoading } = useUserPositionsSummaryData(account, period); + const { + positions: marketPositions, + refetch, + isRefetching, + isEarningsLoading, + actualBlockData, + } = useUserPositionsSummaryData(account, period); const [expandedRows, setExpandedRows] = useState>(new Set()); const [showRebalanceModal, setShowRebalanceModal] = useState(false); @@ -217,28 +221,7 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr Network Size {rateLabel} (now) - - - Interest Accrued ({period}) - } - /> - } - > -
- -
-
-
-
+ Interest Accrued ({period}) Collateral Risk Tiers Actions @@ -296,17 +279,41 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr margin={3} />
+ ) : earnings === 0n ? ( + - ) : ( - - {(() => { - if (earnings === 0n) return '-'; + { + const blockData = actualBlockData[groupedPosition.chainId]; + if (!blockData) return 'Loading timestamp data...'; + + const startTimestamp = blockData.timestamp * 1000; + const endTimestamp = Date.now(); + + const formatDateTime = (timestamp: number) => + new Date(timestamp).toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + return ( - formatReadable(Number(formatBalance(earnings, groupedPosition.loanAssetDecimals))) + - ' ' + - groupedPosition.loanAsset + ); })()} - + > + + {formatReadable(Number(formatBalance(earnings, groupedPosition.loanAssetDecimals)))}{' '} + {groupedPosition.loanAsset} + + )}
diff --git a/src/hooks/queries/useBlockTimestamps.ts b/src/hooks/queries/useBlockTimestamps.ts index 6cb34c3c..56b76297 100644 --- a/src/hooks/queries/useBlockTimestamps.ts +++ b/src/hooks/queries/useBlockTimestamps.ts @@ -4,7 +4,7 @@ import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; /** - * + * * @param snapshotBlocks { chainId: blockNumber } */ export const useBlockTimestamps = (snapshotBlocks: Record) => { diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 34196033..d9d2256e 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -166,8 +166,6 @@ const useUserPositions = (user: string | undefined, showEmpty = false, chainIds? marketsByChain.set(marketInfo.chainId, existing); }); - console.log('All markets by chain', marketsByChain); - // Build market data map from allMarkets context (no need to fetch individually) const marketDataMap = new Map(); allMarkets.forEach((market) => { diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 7cda54d5..0802d9f7 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -145,7 +145,7 @@ export const networks: NetworkConfig[] = [ logo: require('../imgs/chains/arbitrum.png') as string, name: 'Arbitrum', defaultRPC: getRpcUrl(process.env.NEXT_PUBLIC_ARBITRUM_RPC, 'arb-mainnet'), - blocktime: 2, + blocktime: 0.25, maxBlockDelay: 2, explorerUrl: 'https://arbiscan.io', wrappedNativeToken: '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', @@ -156,7 +156,7 @@ export const networks: NetworkConfig[] = [ logo: require('../imgs/chains/hyperevm.png') as string, name: 'HyperEVM', defaultRPC: getRpcUrl(process.env.NEXT_PUBLIC_HYPEREVM_RPC, 'hyperliquid-mainnet'), - blocktime: 2, + blocktime: 1, maxBlockDelay: 5, nativeTokenSymbol: 'WHYPE', wrappedNativeToken: '0x5555555555555555555555555555555555555555', @@ -168,7 +168,7 @@ export const networks: NetworkConfig[] = [ logo: require('../imgs/chains/monad.svg') as string, name: 'Monad', defaultRPC: getRpcUrl(process.env.NEXT_PUBLIC_MONAD_RPC, 'monad-mainnet'), - blocktime: 1, + blocktime: 0.4, maxBlockDelay: 5, nativeTokenSymbol: 'MON', wrappedNativeToken: '0x3bd359C1119dA7Da1D913D1C4D2B7c461115433A', From 402d49a2989dd22abc0953300fffce6ffe7e6598 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 19:06:20 +0800 Subject: [PATCH 10/11] chore: clean --- src/hooks/queries/useUserTransactionsQuery.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index 6d5badcb..d48f715b 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -7,7 +7,6 @@ type UseUserTransactionsQueryOptions = { /** * When true, automatically paginates to fetch ALL transactions. * Use for report generation when complete accuracy is needed. - * Use for summary pages when speed is prioritized. */ paginate?: boolean; /** Page size for pagination (default 1000) */ @@ -23,11 +22,6 @@ type UseUserTransactionsQueryOptions = { * - Combines transactions from all target networks * - Sorts by timestamp (descending) * - Supports auto-pagination when paginate=true - * - * Cache behavior: - * - staleTime: 30 seconds (transactions change moderately frequently) - * - Refetch on window focus: enabled - * - Only runs when userAddress is provided * ``` */ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOptions) => { @@ -92,7 +86,7 @@ export const useUserTransactionsQuery = (options: UseUserTransactionsQueryOption }; }, enabled: enabled && filters.userAddress.length > 0, - staleTime: 5 * 60 * 1000, + staleTime: 60 * 1000, refetchOnWindowFocus: false, }); }; From be115aceb4314d0685a3b6bd167f9e6260ae5067 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 19:14:18 +0800 Subject: [PATCH 11/11] misc: cleanup --- .../supplied-morpho-blue-grouped-table.tsx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 0f9bfbd1..fb3f25e9 100644 --- a/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx +++ b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx @@ -7,6 +7,7 @@ import { ReloadIcon } from '@radix-ui/react-icons'; import { GearIcon } from '@radix-ui/react-icons'; import { motion, AnimatePresence } from 'framer-motion'; import Image from 'next/image'; +import moment from 'moment'; import { PulseLoader } from 'react-spinners'; import { useConnection } from 'wagmi'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; @@ -290,21 +291,10 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr const startTimestamp = blockData.timestamp * 1000; const endTimestamp = Date.now(); - const formatDateTime = (timestamp: number) => - new Date(timestamp).toLocaleString('en-US', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - }); - return ( ); })()}