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();
- }
-}