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/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..8c8c3fc0 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; } @@ -33,7 +32,7 @@ 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 { diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts index 0188b0b1..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'; @@ -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; } @@ -174,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/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 8148f1e6..944c9ca3 100644 --- a/src/features/positions-report/positions-report-view.tsx +++ b/src/features/positions-report/positions-report-view.tsx @@ -24,6 +24,8 @@ 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, true); const [selectedAsset, setSelectedAsset] = useState(null); @@ -53,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; @@ -101,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 { @@ -233,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/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/components/supplied-morpho-blue-grouped-table.tsx b/src/features/positions/components/supplied-morpho-blue-grouped-table.tsx index 48af610f..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,8 +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 { BsQuestionCircle } from 'react-icons/bs'; -import { PiHandCoins } from 'react-icons/pi'; +import moment from 'moment'; import { PulseLoader } from 'react-spinners'; import { useConnection } from 'wagmi'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; @@ -19,16 +18,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 +95,23 @@ 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, + actualBlockData, + } = 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,12 +125,11 @@ export function SuppliedMorphoBlueGroupedTable({ return account === address; }, [account, address]); - const periodLabels: Record = { - all: 'All Time', + const periodLabels = { day: '1D', week: '7D', month: '30D', - }; + } as const; const groupedPositions = useMemo(() => groupPositionsByLoanAsset(marketPositions), [marketPositions]); @@ -227,28 +222,7 @@ export function SuppliedMorphoBlueGroupedTable({ Network Size {rateLabel} (now) - - - Interest Accrued ({earningsPeriod}) - } - /> - } - > -
- -
-
-
-
+ Interest Accrued ({period}) Collateral Risk Tiers Actions @@ -296,9 +270,9 @@ export function SuppliedMorphoBlueGroupedTable({ {formatReadable((isAprDisplay ? convertApyToApr(avgApy) : avgApy) * 100)}%
- +
- {isLoadingEarnings ? ( + {isEarningsLoading ? (
+ ) : 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(); + return ( - formatReadable(Number(formatBalance(earnings, groupedPosition.loanAssetDecimals))) + - ' ' + - groupedPosition.loanAsset + ); })()} - + > + + {formatReadable(Number(formatBalance(earnings, groupedPosition.loanAssetDecimals)))}{' '} + {groupedPosition.loanAsset} + + )}
@@ -421,17 +408,17 @@ export function SuppliedMorphoBlueGroupedTable({ helper="Select the time period for interest accrued calculations" >
- {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 45bd2c33..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,24 +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.blocks) return 'Fetching block numbers...'; - 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 (
@@ -102,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 && ( @@ -132,35 +95,10 @@ export default function Positions() { {/* Empty state (only if both finished loading and both empty) */} {showEmpty && ( -
-
- - } - > - - - - -
-
- -
-
+ )}
diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 3000fb3e..cf753d95 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -293,14 +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 + $timestamp_gt: BigInt! + $timestamp_lt: BigInt! + ${useMarketFilter ? '$market_in: [Bytes!]}' : ''} ) { account(id: $userId) { deposits( @@ -311,6 +315,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + ${additionalQuery} } ) { id @@ -331,6 +336,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + ${additionalQuery} } ) { id @@ -351,6 +357,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + ${additionalQuery} } ) { id @@ -370,6 +377,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + ${additionalQuery} } ) { id @@ -389,6 +397,7 @@ export const subgraphUserTransactionsQuery = ` where: { timestamp_gt: $timestamp_gt timestamp_lt: $timestamp_lt + ${additionalQuery} } ) { id @@ -402,6 +411,7 @@ export const subgraphUserTransactionsQuery = ` } } `; +}; export const marketPositionsQuery = ` query getMarketPositions($market: String!, $minShares: BigInt!, $first: Int!, $skip: Int!) { diff --git a/src/hooks/queries/useBlockTimestamps.ts b/src/hooks/queries/useBlockTimestamps.ts new file mode 100644 index 00000000..56b76297 --- /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/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts index 03db9967..d48f715b 100644 --- a/src/hooks/queries/useUserTransactionsQuery.ts +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -1,6 +1,18 @@ 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. + */ + paginate?: boolean; + /** Page size for pagination (default 1000) */ + pageSize?: number; +}; + /** * Fetches user transactions from Morpho API or Subgraph using React Query. * @@ -9,27 +21,11 @@ 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 - * - * Cache behavior: - * - staleTime: 30 seconds (transactions change moderately frequently) - * - Refetch on window focus: enabled - * - Only runs when userAddress is provided - * - * @example - * ```tsx - * const { data, isLoading, error } = useUserTransactionsQuery({ - * filters: { - * userAddress: ['0x...'], - * chainIds: [1, 8453], - * first: 10, - * skip: 0, - * }, - * }); + * - Supports auto-pagination when 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({ queryKey: [ @@ -43,10 +39,54 @@ 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 + return await fetchUserTransactions({ + ...filters, + first: pageSize, + }); + } + + // 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, + }; + }, enabled: enabled && filters.userAddress.length > 0, - staleTime: 30_000, // 30 seconds - transactions change moderately frequently - refetchOnWindowFocus: true, + staleTime: 60 * 1000, + refetchOnWindowFocus: false, }); }; diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index 7fe38767..c84d6528 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'; @@ -27,6 +28,10 @@ export type ReportSummary = { period: number; marketReports: PositionReport[]; groupedEarnings: EarningsCalculation; + startBlock: number; + endBlock: number; + startTimestamp: number; + endTimestamp: number; }; export const usePositionReport = ( @@ -48,26 +53,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 +85,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, + timestampLte: actualEndTimestamp, + assetIds: [selectedAsset.address], first: PAGE_SIZE, skip, }); @@ -93,24 +102,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 +134,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 +171,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, @@ -172,6 +186,10 @@ export const usePositionReport = ( period, marketReports, groupedEarnings, + startBlock: startBlockEstimate, + endBlock: endBlockEstimate, + startTimestamp: actualStartTimestamp, + endTimestamp: actualEndTimestamp, }; }; diff --git a/src/hooks/usePositionsWithEarnings.ts b/src/hooks/usePositionsWithEarnings.ts new file mode 100644 index 00000000..d32fac03 --- /dev/null +++ b/src/hooks/usePositionsWithEarnings.ts @@ -0,0 +1,61 @@ +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); + switch (period) { + case 'day': + return now - 86_400; + case 'week': + return now - 7 * 86_400; + case 'month': + return now - 30 * 86_400; + default: + return now - 86_400; + } +}; + +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..fb550767 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -1,232 +1,92 @@ 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 { fetchPositionsSnapshots, type PositionSnapshot } from '@/utils/positions'; -import { estimatedBlockNumber, getClient } from '@/utils/rpc'; -import type { MarketPositionWithEarnings, UserTransaction } from '@/utils/types'; +import { useQueryClient } from '@tanstack/react-query'; +import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; +import type { SupportedNetworks } from '@/utils/networks'; import useUserPositions, { positionKeys } from './useUserPositions'; -import { fetchUserTransactions } from './queries/fetchUserTransactions'; +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'; -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; -}; - -const useUserPositionsSummaryData = (user: string | undefined, period: EarningsPeriod = 'all', chainIds?: SupportedNetworks[]) => { - const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, true, chainIds); +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], - ); - - // Query for block numbers for the selected period - const { data: periodBlockNumbers, isLoading: isLoadingBlocks } = useQuery({ - queryKey: blockKeys.period(period, chainIds?.join(',')), - queryFn: async () => fetchPeriodBlockNumbers(period, chainIds), - enabled: period !== 'all', - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 3 * 60 * 1000, - }); - - // Query for snapshots at the period's block (batched by chain) - const { data: periodSnapshots, isLoading: isLoadingSnapshots } = useQuery({ - queryKey: ['period-snapshots', user, period, positionsKey, JSON.stringify(periodBlockNumbers)], - queryFn: async () => { - if (!positions || !user) return new Map(); - if (period === 'all') return new Map(); - if (!periodBlockNumbers) return new Map(); - - // Group positions by chain - const positionsByChain = new Map(); - positions.forEach((pos) => { - const chainId = pos.market.morphoBlue.chain.id; - const existing = positionsByChain.get(chainId) ?? []; - existing.push(pos.market.uniqueKey); - positionsByChain.set(chainId, existing); - }); - - // Batch fetch snapshots for each chain - const allSnapshots = new Map(); - await Promise.all( - Array.from(positionsByChain.entries()).map(async ([chainId, marketIds]) => { - const blockNumber = periodBlockNumbers[chainId]; - if (!blockNumber) return; - - const client = getClient(chainId as SupportedNetworks, customRpcUrls[chainId as SupportedNetworks]); - const snapshots = await fetchPositionsSnapshots(marketIds, user as Address, chainId, blockNumber, client); + const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, false, chainIds); - snapshots.forEach((snapshot, marketId) => { - allSnapshots.set(marketId.toLowerCase(), snapshot); - }); - }), - ); - - return allSnapshots; - }, - enabled: !!positions && !!user && (period === 'all' || !!periodBlockNumbers), - staleTime: 30_000, - gcTime: 5 * 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 { data: currentBlocks } = useCurrentBlocks(uniqueChainIds); - const result = await fetchUserTransactions({ - userAddress: [user], - marketUniqueKeys: positions.map((p) => p.market.uniqueKey), - chainIds: uniqueChainIds, - }); + const snapshotBlocks = useMemo(() => { + if (!currentBlocks) return {}; - return result; - }, - 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 timestamp = getPeriodTimestamp(period); + const blocks: Record = {}; - // 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' })); - } - - // 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' })); - } + uniqueChainIds.forEach((chainId) => { + const currentBlock = currentBlocks[chainId]; + if (currentBlock) { + blocks[chainId] = estimateBlockAtTimestamp(chainId, timestamp, currentBlock); + } + }); - const now = Math.floor(Date.now() / 1000); - const startTimestamp = getPeriodTimestamp(period); + return blocks; + }, [period, uniqueChainIds, currentBlocks]); - return positions.map((position) => { - const currentBalance = BigInt(position.state.supplyAssets); - const marketId = position.market.uniqueKey; - const marketIdLower = marketId.toLowerCase(); + const { data: actualBlockData } = useBlockTimestamps(snapshotBlocks); - // Get past balance from snapshot (0 for lifetime) - const pastSnapshot = periodSnapshots?.get(marketIdLower); - const pastBalance = pastSnapshot ? BigInt(pastSnapshot.supplyAssets) : 0n; + const endTimestamp = useMemo(() => Math.floor(Date.now() / 1000), []); - // Filter transactions for this market (case-insensitive comparison) - const marketTxs = (allTransactions ?? []).filter( - (tx: UserTransaction) => tx.data?.market?.uniqueKey?.toLowerCase() === marketIdLower, - ); + const { data: txData, isLoading: isLoadingTransactions } = useUserTransactionsQuery({ + filters: { + userAddress: user ? [user] : [], + marketUniqueKeys: positions?.map((p) => p.market.uniqueKey) ?? [], + chainIds: uniqueChainIds, + }, + paginate: true, + enabled: !!positions && !!user, + }); - // Calculate earnings - const earnings = calculateEarningsFromSnapshot(currentBalance, pastBalance, marketTxs, startTimestamp, now); + const { data: allSnapshots, isLoading: isLoadingSnapshots } = usePositionSnapshots({ + positions, + user, + snapshotBlocks, + }); - 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 +95,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, }; @@ -253,6 +111,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP 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/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; - } -} 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', 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(); - } -}