From b33c30aec11e5c422bc93639be41a5b4d5da057c Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 11:29:56 +0800 Subject: [PATCH 1/4] refactor: useQuery --- src/contexts/VaultRegistryContext.tsx | 4 +- src/data-sources/morpho-api/transactions.ts | 2 +- src/data-sources/subgraph/transactions.ts | 2 +- .../deployment/deployment-modal.tsx | 4 +- .../components/deployment/token-selection.tsx | 2 +- .../history/components/history-table.tsx | 82 +++---- .../transaction-history-preview.tsx | 39 ++-- .../components/onboarding/asset-selection.tsx | 4 +- .../onboarding/onboarding-context.tsx | 4 +- src/features/positions/positions-view.tsx | 8 +- .../swap/components/BridgeSwapModal.tsx | 4 +- src/hooks/queries/fetchUserTransactions.ts | 179 +++++++++++++++ src/hooks/queries/useAllMorphoVaultsQuery.ts | 32 +++ src/hooks/{ => queries}/useAllocations.ts | 2 +- src/hooks/queries/useUserBalancesQuery.ts | 152 +++++++++++++ src/hooks/queries/useUserTransactionsQuery.ts | 52 +++++ .../useUserVaultsV2Query.ts} | 96 ++++---- src/hooks/useAllMorphoVaults.ts | 53 ----- src/hooks/useLocalStorage.ts | 63 ------ src/hooks/usePositionReport.ts | 6 +- src/hooks/useUserBalances.ts | 158 -------------- src/hooks/useUserPositionsSummaryData.ts | 11 +- src/hooks/useUserRebalancerInfo.ts | 81 ------- src/hooks/useUserTransactions.ts | 205 ------------------ src/hooks/useVaultAllocations.ts | 4 +- src/modals/settings/trusted-vaults-modal.tsx | 4 +- src/stores/useTransactionFilters.ts | 1 - 27 files changed, 546 insertions(+), 708 deletions(-) create mode 100644 src/hooks/queries/fetchUserTransactions.ts create mode 100644 src/hooks/queries/useAllMorphoVaultsQuery.ts rename src/hooks/{ => queries}/useAllocations.ts (93%) create mode 100644 src/hooks/queries/useUserBalancesQuery.ts create mode 100644 src/hooks/queries/useUserTransactionsQuery.ts rename src/hooks/{useUserVaultsV2.ts => queries/useUserVaultsV2Query.ts} (65%) delete mode 100644 src/hooks/useAllMorphoVaults.ts delete mode 100644 src/hooks/useLocalStorage.ts delete mode 100644 src/hooks/useUserBalances.ts delete mode 100644 src/hooks/useUserRebalancerInfo.ts delete mode 100644 src/hooks/useUserTransactions.ts diff --git a/src/contexts/VaultRegistryContext.tsx b/src/contexts/VaultRegistryContext.tsx index 0358caa5..628faffe 100644 --- a/src/contexts/VaultRegistryContext.tsx +++ b/src/contexts/VaultRegistryContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useMemo, type ReactNode } from 'react'; import type { MorphoVault } from '@/data-sources/morpho-api/vaults'; -import { useAllMorphoVaults } from '@/hooks/useAllMorphoVaults'; +import { useAllMorphoVaultsQuery } from '@/hooks/queries/useAllMorphoVaultsQuery'; import type { Address } from 'viem'; type VaultRegistryContextType = { @@ -15,7 +15,7 @@ type VaultRegistryContextType = { const VaultRegistryContext = createContext(undefined); export function VaultRegistryProvider({ children }: { children: ReactNode }) { - const { vaults, loading, error } = useAllMorphoVaults(); + const { data: vaults = [], isLoading: loading, error } = useAllMorphoVaultsQuery(); const getVaultByAddress = useMemo( () => (address: Address, chainId?: number) => { diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 693de57d..349c9d1f 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -1,5 +1,5 @@ import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; -import type { TransactionFilters, TransactionResponse } from '@/hooks/useUserTransactions'; +import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/useUserTransactionsQuery'; import { SupportedNetworks } from '@/utils/networks'; import { morphoGraphqlFetcher } from './fetchers'; diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts index 3fa36b1a..95700433 100644 --- a/src/data-sources/subgraph/transactions.ts +++ b/src/data-sources/subgraph/transactions.ts @@ -1,5 +1,5 @@ import { subgraphUserTransactionsQuery } from '@/graphql/morpho-subgraph-queries'; -import type { TransactionFilters, TransactionResponse } from '@/hooks/useUserTransactions'; +import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/useUserTransactionsQuery'; import type { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; import { type UserTransaction, UserTxTypes } from '@/utils/types'; diff --git a/src/features/autovault/components/deployment/deployment-modal.tsx b/src/features/autovault/components/deployment/deployment-modal.tsx index 33b9dd54..0c1fd9c5 100644 --- a/src/features/autovault/components/deployment/deployment-modal.tsx +++ b/src/features/autovault/components/deployment/deployment-modal.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import { useUserBalances } from '@/hooks/useUserBalances'; +import { useUserBalancesQuery } from '@/hooks/queries/useUserBalancesQuery'; import { ALL_SUPPORTED_NETWORKS, isAgentAvailable, SupportedNetworks } from '@/utils/networks'; import { DeploymentProvider, useDeployment } from '@/features/autovault/components/deployment/deployment-context'; import { TokenSelection } from './token-selection'; @@ -22,7 +22,7 @@ function DeploymentModalContent({ isOpen, onOpenChange }: DeploymentModalContent const { selectedTokenAndNetwork, createVault, isDeploying, deploymentPhase, deployedVaultAddress, navigateToVault } = useDeployment(); // Load balances and tokens at modal level - const { balances, loading: balancesLoading } = useUserBalances({ + const { data: balances = [], isLoading: balancesLoading } = useUserBalancesQuery({ networkIds: VAULT_SUPPORTED_NETWORKS, }); const { whitelistedMarkets, loading: marketsLoading } = useProcessedMarkets(); diff --git a/src/features/autovault/components/deployment/token-selection.tsx b/src/features/autovault/components/deployment/token-selection.tsx index 6f8c59b6..2783ffa9 100644 --- a/src/features/autovault/components/deployment/token-selection.tsx +++ b/src/features/autovault/components/deployment/token-selection.tsx @@ -4,7 +4,7 @@ import { type Address, formatUnits } from 'viem'; import { Spinner } from '@/components/ui/spinner'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { TokenIcon } from '@/components/shared/token-icon'; -import type { TokenBalance } from '@/hooks/useUserBalances'; +import type { TokenBalance } from '@/hooks/queries/useUserBalancesQuery'; import { formatReadable } from '@/utils/balance'; import { getNetworkImg, SupportedNetworks } from '@/utils/networks'; import type { Market } from '@/utils/types'; diff --git a/src/features/history/components/history-table.tsx b/src/features/history/components/history-table.tsx index 1851975b..9c072e2a 100644 --- a/src/features/history/components/history-table.tsx +++ b/src/features/history/components/history-table.tsx @@ -25,7 +25,7 @@ import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/ import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; import { RebalanceDetail } from './rebalance-detail'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import useUserTransactions from '@/hooks/useUserTransactions'; +import { useUserTransactionsQuery } from '@/hooks/queries/useUserTransactionsQuery'; import { useDisclosure } from '@/hooks/useDisclosure'; import { useHistoryPreferences } from '@/stores/useHistoryPreferences'; import { useStyledToast } from '@/hooks/useStyledToast'; @@ -80,11 +80,7 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His const { allMarkets } = useProcessedMarkets(); const toast = useStyledToast(); - const { loading, fetchTransactions } = useUserTransactions(); const [currentPage, setCurrentPage] = useState(1); - const [history, setHistory] = useState([]); - const [isInitialized, setIsInitialized] = useState(false); - const [totalPages, setTotalPages] = useState(0); // Settings state from Zustand store const { entriesPerPage: pageSize, setEntriesPerPage: setPageSize, isGroupedView, setIsGroupedView } = useHistoryPreferences(); @@ -94,6 +90,34 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His // Temporary input state for settings modal const [customPageSize, setCustomPageSize] = useState(pageSize.toString()); + // Get filtered market IDs based on selected asset + const marketIdFilter = useMemo(() => { + if (!selectedAsset) return []; + + return allMarkets + .filter((m) => m.loanAsset.symbol === selectedAsset.symbol && m.morphoBlue.chain.id === selectedAsset.chainId) + .map((m) => m.uniqueKey); + }, [selectedAsset, allMarkets]); + + // Fetch transactions using React Query + const { + data, + isLoading: loading, + refetch, + } = useUserTransactionsQuery({ + filters: { + userAddress: account ? [account] : [], + first: pageSize, + skip: (currentPage - 1) * pageSize, + marketUniqueKeys: marketIdFilter, + }, + enabled: Boolean(account) && allMarkets.length > 0, + }); + + const history = data?.items ?? []; + const totalPages = data ? Math.ceil(data.pageInfo.countTotal / pageSize) : 0; + const isInitialized = !loading; + // Group transactions for display (especially useful for vault adapter mode) const groupedHistory = useMemo(() => groupTransactionsByHash(history), [history]); @@ -131,22 +155,12 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His const handleManualRefresh = () => { void (async () => { - if (!account || !fetchTransactions || allMarkets.length === 0) return; + if (!account || allMarkets.length === 0) return; - const result = await fetchTransactions({ - userAddress: [account], - first: pageSize, - skip: (currentPage - 1) * pageSize, - marketUniqueKeys: marketIdFilter, + await refetch(); + toast.info('Data updated', 'Transaction history updated', { + icon: 🔄, }); - - if (result) { - setHistory(result.items); - setTotalPages(Math.ceil(result.pageInfo.countTotal / pageSize)); - toast.info('Data updated', 'Transaction history updated', { - icon: 🔄, - }); - } })(); }; @@ -219,36 +233,6 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His setCurrentPage(1); }, [isGroupedView, pageSize]); - // Get filtered market IDs based on selected asset - const marketIdFilter = useMemo(() => { - if (!selectedAsset) return []; - - return allMarkets - .filter((m) => m.loanAsset.symbol === selectedAsset.symbol && m.morphoBlue.chain.id === selectedAsset.chainId) - .map((m) => m.uniqueKey); - }, [selectedAsset, allMarkets]); - - useEffect(() => { - const loadTransactions = async () => { - if (!account || !fetchTransactions || allMarkets.length === 0) return; - - const result = await fetchTransactions({ - userAddress: [account], - first: pageSize, - skip: (currentPage - 1) * pageSize, - marketUniqueKeys: marketIdFilter, - }); - - if (result) { - setHistory(result.items); - setTotalPages(Math.ceil(result.pageInfo.countTotal / pageSize)); - } - setIsInitialized(true); - }; - - void loadTransactions(); - }, [account, currentPage, fetchTransactions, marketIdFilter]); - useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { diff --git a/src/features/history/components/transaction-history-preview.tsx b/src/features/history/components/transaction-history-preview.tsx index 1e4e6587..18663bc0 100644 --- a/src/features/history/components/transaction-history-preview.tsx +++ b/src/features/history/components/transaction-history-preview.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import Link from 'next/link'; import { formatUnits } from 'viem'; import { motion } from 'framer-motion'; @@ -10,7 +10,7 @@ import { TransactionIdentity } from '@/components/shared/transaction-identity'; import { TableContainerWithDescription } from '@/components/common/table-container-with-header'; import { MarketIdentity, MarketIdentityMode } from '@/features/markets/components/market-identity'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import useUserTransactions from '@/hooks/useUserTransactions'; +import { useUserTransactionsQuery } from '@/hooks/queries/useUserTransactionsQuery'; import { formatReadable } from '@/utils/balance'; import { groupTransactionsByHash, getWithdrawals, getSupplies } from '@/utils/transactionGrouping'; import { getTruncatedAssetName } from '@/utils/oracle'; @@ -54,32 +54,25 @@ export function TransactionHistoryPreview({ limit = 10, emptyMessage, }: TransactionHistoryPreviewProps) { - const { loading, fetchTransactions } = useUserTransactions(); - const [history, setHistory] = useState>([]); - const [isInitialized, setIsInitialized] = useState(false); const [isViewAllHovered, setIsViewAllHovered] = useState(false); const { allMarkets } = useProcessedMarkets(); - useEffect(() => { - const loadTransactions = async () => { - if (!account || !fetchTransactions || allMarkets.length === 0) return; + const { data, isLoading: loading } = useUserTransactionsQuery({ + filters: { + userAddress: account ? [account] : [], + first: limit, + skip: 0, + chainIds: chainId ? [chainId] : undefined, + }, + enabled: Boolean(account) && allMarkets.length > 0, + }); - const result = await fetchTransactions({ - userAddress: [account], - first: limit, - skip: 0, - chainIds: chainId ? [chainId] : undefined, - }); + const history = useMemo(() => { + if (!data) return []; + return groupTransactionsByHash(data.items); + }, [data]); - if (result) { - const grouped = groupTransactionsByHash(result.items); - setHistory(grouped); - } - setIsInitialized(true); - }; - - void loadTransactions(); - }, [account, chainId, limit, fetchTransactions, allMarkets.length]); + const isInitialized = !loading; const historyLink = useMemo(() => { const params = new URLSearchParams(); diff --git a/src/features/positions/components/onboarding/asset-selection.tsx b/src/features/positions/components/onboarding/asset-selection.tsx index 713b2107..f3831bd6 100644 --- a/src/features/positions/components/onboarding/asset-selection.tsx +++ b/src/features/positions/components/onboarding/asset-selection.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import { useUserBalancesAllNetworks } from '@/hooks/useUserBalances'; +import { useUserBalancesAllNetworksQuery } from '@/hooks/queries/useUserBalancesQuery'; import { formatReadable } from '@/utils/balance'; import { getNetworkImg } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; @@ -27,7 +27,7 @@ function NetworkIcon({ networkId }: { networkId: number }) { } export function AssetSelection() { - const { balances, loading: balancesLoading } = useUserBalancesAllNetworks(); + const { data: balances = [], isLoading: balancesLoading } = useUserBalancesAllNetworksQuery(); const { markets, loading: marketsLoading } = useProcessedMarkets(); const { setSelectedToken, setSelectedMarkets, goToNextStep } = useOnboarding(); diff --git a/src/features/positions/components/onboarding/onboarding-context.tsx b/src/features/positions/components/onboarding/onboarding-context.tsx index 2b6f0d56..52f031cb 100644 --- a/src/features/positions/components/onboarding/onboarding-context.tsx +++ b/src/features/positions/components/onboarding/onboarding-context.tsx @@ -1,5 +1,5 @@ import { createContext, useContext, useState, useMemo, useCallback } from 'react'; -import { useUserBalancesAllNetworks } from '@/hooks/useUserBalances'; +import { useUserBalancesAllNetworksQuery } from '@/hooks/queries/useUserBalancesQuery'; import type { Market } from '@/utils/types'; import type { TokenWithMarkets } from './types'; @@ -45,7 +45,7 @@ export function OnboardingProvider({ children }: { children: React.ReactNode }) const [currentStep, setStep] = useState('asset-selection'); // Fetch user balances once for the entire onboarding flow - const { balances, loading: balancesLoading } = useUserBalancesAllNetworks(); + const { data: balances = [], isLoading: balancesLoading } = useUserBalancesAllNetworksQuery(); const currentStepIndex = ONBOARDING_STEPS.findIndex((s) => s.id === currentStep); diff --git a/src/features/positions/positions-view.tsx b/src/features/positions/positions-view.tsx index 9f50a696..45bd2c33 100644 --- a/src/features/positions/positions-view.tsx +++ b/src/features/positions/positions-view.tsx @@ -15,7 +15,7 @@ import { TooltipContent } from '@/components/shared/tooltip-content'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; import useUserPositionsSummaryData, { type EarningsPeriod } from '@/hooks/useUserPositionsSummaryData'; import { usePortfolioValue } from '@/hooks/usePortfolioValue'; -import { useUserVaultsV2 } from '@/hooks/useUserVaultsV2'; +import { useUserVaultsV2Query } from '@/hooks/queries/useUserVaultsV2Query'; import { SuppliedMorphoBlueGroupedTable } from './components/supplied-morpho-blue-grouped-table'; import { PortfolioValueBadge } from './components/portfolio-value-badge'; import { UserVaultsTable } from './components/user-vaults-table'; @@ -37,7 +37,11 @@ export default function Positions() { } = useUserPositionsSummaryData(account, earningsPeriod); // Fetch user's auto vaults - const { vaults, loading: isVaultsLoading, refetch: refetchVaults } = useUserVaultsV2(account); + const { + data: vaults = [], + isLoading: isVaultsLoading, + refetch: refetchVaults, + } = useUserVaultsV2Query({ userAddress: account as Address }); // Calculate portfolio value from positions and vaults const { totalUsd, isLoading: isPricesLoading, error: pricesError } = usePortfolioValue(marketPositions, vaults); diff --git a/src/features/swap/components/BridgeSwapModal.tsx b/src/features/swap/components/BridgeSwapModal.tsx index 7f143709..a86061e3 100644 --- a/src/features/swap/components/BridgeSwapModal.tsx +++ b/src/features/swap/components/BridgeSwapModal.tsx @@ -6,7 +6,7 @@ import { motion } from 'framer-motion'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/components/common/Modal'; import { Button } from '@/components/ui/button'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; -import { useUserBalances } from '@/hooks/useUserBalances'; +import { useUserBalancesQuery } from '@/hooks/queries/useUserBalancesQuery'; import { useAllowance } from '@/hooks/useAllowance'; import { formatBalance } from '@/utils/balance'; import { useCowBridge } from '../hooks/useCowBridge'; @@ -28,7 +28,7 @@ export function BridgeSwapModal({ isOpen, onClose, targetToken }: BridgeSwapModa const [slippage, _setSlippage] = useState(DEFAULT_SLIPPAGE_PERCENT); // Fetch user balances from CoW-supported chains - const { balances, loading: balancesLoading } = useUserBalances({ + const { data: balances = [], isLoading: balancesLoading } = useUserBalancesQuery({ networkIds: COW_BRIDGE_CHAINS as unknown as number[], }); diff --git a/src/hooks/queries/fetchUserTransactions.ts b/src/hooks/queries/fetchUserTransactions.ts new file mode 100644 index 00000000..c3a9468b --- /dev/null +++ b/src/hooks/queries/fetchUserTransactions.ts @@ -0,0 +1,179 @@ +import { supportsMorphoApi } from '@/config/dataSources'; +import { fetchMorphoTransactions } from '@/data-sources/morpho-api/transactions'; +import { fetchSubgraphTransactions } from '@/data-sources/subgraph/transactions'; +import { SupportedNetworks, isSupportedChain } from '@/utils/networks'; +import type { UserTransaction } from '@/utils/types'; + +export type TransactionFilters = { + userAddress: string[]; + marketUniqueKeys?: string[]; + chainIds?: number[]; + timestampGte?: number; + timestampLte?: number; + skip?: number; + first?: number; + hash?: string; + assetIds?: string[]; +}; + +export type TransactionResponse = { + items: UserTransaction[]; + pageInfo: { + count: number; + countTotal: number; + }; + error: string | null; +}; + +const MAX_ITEMS_PER_SOURCE = 1000; + +/** + * Standalone function to fetch user transactions from Morpho API or Subgraph. + * Can be used imperatively in hooks that need to fetch transactions as part of a larger operation. + * + * @param filters - Transaction filters + * @returns Promise resolving to transaction response + */ +export async function fetchUserTransactions(filters: TransactionFilters): Promise { + // 1. Determine target networks (numeric enum values) + let targetNetworks: SupportedNetworks[]; + + if (filters.chainIds && filters.chainIds.length > 0) { + // Filter provided chainIds to only include valid, supported numeric values + targetNetworks = filters.chainIds.filter(isSupportedChain) as SupportedNetworks[]; + } else { + // Default to all supported networks (get only numeric values from enum) + targetNetworks = Object.values(SupportedNetworks).filter((value) => typeof value === 'number') as SupportedNetworks[]; + } + + if (targetNetworks.length === 0) { + console.warn('No valid target networks determined.'); + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: null, + }; + } + + // Check for subgraph user address limitation + const usesSubgraph = targetNetworks.some((network) => !supportsMorphoApi(network)); + if (usesSubgraph && filters.userAddress.length !== 1) { + const errorMsg = 'Subgraph data source requires exactly one user address.'; + console.error(errorMsg); + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: errorMsg, + }; + } + + // 2. Create fetch promises for each network + const results = await Promise.allSettled( + targetNetworks.map(async (network) => { + let networkItems: UserTransaction[] = []; + let networkError: string | null = null; + + // Try Morpho API first if supported + if (supportsMorphoApi(network)) { + try { + const morphoFilters = { + ...filters, + chainIds: [network], + first: MAX_ITEMS_PER_SOURCE, + skip: 0, + }; + const morphoResponse = await fetchMorphoTransactions(morphoFilters); + if (!morphoResponse.error) { + networkItems = morphoResponse.items; + return { + items: networkItems, + pageInfo: { + count: networkItems.length, + countTotal: networkItems.length, + }, + error: null, + }; + } + networkError = morphoResponse.error; + } catch (morphoError) { + networkError = `Failed to fetch from Morpho API: ${(morphoError as Error)?.message || 'Unknown error'}`; + } + } + + // Only try Subgraph if Morpho API failed or is not supported + if (!supportsMorphoApi(network) || networkError) { + try { + const subgraphFilters = { + ...filters, + chainIds: [network], + first: MAX_ITEMS_PER_SOURCE, + skip: 0, + }; + const subgraphResponse = await fetchSubgraphTransactions(subgraphFilters, network); + if (!subgraphResponse.error) { + networkItems = subgraphResponse.items; + return { + items: networkItems, + pageInfo: { + count: networkItems.length, + countTotal: networkItems.length, + }, + error: null, + }; + } + networkError = subgraphResponse.error; + } catch (subgraphError) { + networkError = `Failed to fetch from Subgraph: ${(subgraphError as Error)?.message || 'Unknown error'}`; + } + } + + // Only reach here if both Morpho API and Subgraph failed + return { + items: networkItems, + pageInfo: { + count: networkItems.length, + countTotal: networkItems.length, + }, + error: networkError, + }; + }), + ); + + // 3. Combine results + let combinedItems: UserTransaction[] = []; + let combinedTotalCount = 0; + const errors: string[] = []; + + results.forEach((result) => { + if (result.status === 'fulfilled') { + const response = result.value; + if (response.error) { + errors.push(response.error); + } else { + combinedItems = combinedItems.concat(response.items); + combinedTotalCount += response.pageInfo.countTotal; + } + } else { + errors.push(`Failed to fetch transactions: ${result.reason?.message || 'Unknown error'}`); + } + }); + + // 4. Sort combined results by timestamp + combinedItems.sort((a, b) => b.timestamp - a.timestamp); + + // 5. Apply client-side pagination + const skip = filters.skip ?? 0; + const first = filters.first ?? combinedItems.length; + const paginatedItems = combinedItems.slice(skip, skip + first); + + const finalError = errors.length > 0 ? errors.join('; ') : null; + + return { + items: paginatedItems, + pageInfo: { + count: paginatedItems.length, + countTotal: combinedTotalCount, + }, + error: finalError, + }; +} diff --git a/src/hooks/queries/useAllMorphoVaultsQuery.ts b/src/hooks/queries/useAllMorphoVaultsQuery.ts new file mode 100644 index 00000000..e09df531 --- /dev/null +++ b/src/hooks/queries/useAllMorphoVaultsQuery.ts @@ -0,0 +1,32 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchAllMorphoVaults, type MorphoVault } from '@/data-sources/morpho-api/vaults'; + +/** + * Fetches all whitelisted Morpho vaults from the API using React Query. + * + * Cache behavior: + * - staleTime: 5 minutes (data considered fresh) + * - Auto-refetch: Every 5 minutes in background + * - Refetch on window focus: enabled + * + * @example + * ```tsx + * const { data: vaults, isLoading, error, refetch } = useAllMorphoVaultsQuery(); + * ``` + */ +export const useAllMorphoVaultsQuery = () => { + return useQuery({ + queryKey: ['morpho-vaults'], + queryFn: async () => { + try { + return await fetchAllMorphoVaults(); + } catch (err) { + console.error('Error fetching Morpho vaults:', err); + throw err instanceof Error ? err : new Error('Failed to fetch Morpho vaults'); + } + }, + staleTime: 5 * 60 * 1000, // Data is fresh for 5 minutes + refetchInterval: 5 * 60 * 1000, // Auto-refetch every 5 minutes in background + refetchOnWindowFocus: true, // Refetch when user returns to tab + }); +}; diff --git a/src/hooks/useAllocations.ts b/src/hooks/queries/useAllocations.ts similarity index 93% rename from src/hooks/useAllocations.ts rename to src/hooks/queries/useAllocations.ts index 5a3cd7d7..c0b8d7df 100644 --- a/src/hooks/useAllocations.ts +++ b/src/hooks/queries/useAllocations.ts @@ -18,7 +18,7 @@ type UseAllocationsArgs = { enabled?: boolean; }; -export function useAllocations({ vaultAddress, chainId, caps = [], enabled = true }: UseAllocationsArgs) { +export function useAllocationsQuery({ vaultAddress, chainId, caps = [], enabled = true }: UseAllocationsArgs) { // Create a stable key from capIds to detect actual changes const capsKey = useMemo(() => { return caps diff --git a/src/hooks/queries/useUserBalancesQuery.ts b/src/hooks/queries/useUserBalancesQuery.ts new file mode 100644 index 00000000..82d3e567 --- /dev/null +++ b/src/hooks/queries/useUserBalancesQuery.ts @@ -0,0 +1,152 @@ +import { useQuery } from '@tanstack/react-query'; +import { useConnection } from 'wagmi'; +import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; +import { type SupportedNetworks, ALL_SUPPORTED_NETWORKS } from '@/utils/networks'; + +export type TokenBalance = { + address: string; + balance: string; + chainId: number; + decimals: number; + symbol: string; +}; + +type TokenResponse = { + tokens: { + address: string; + balance: string; + }[]; +}; + +type UseUserBalancesOptions = { + address?: string; + networkIds?: SupportedNetworks[]; + enabled?: boolean; +}; + +/** + * Fetches user token balances across specified networks using React Query. + * + * Data fetching strategy: + * - Fetches balances from API endpoint for each network + * - Enriches balance data with token metadata from useTokensQuery + * - Filters out tokens not found in token registry + * - Gracefully handles individual network failures + * + * Cache behavior: + * - staleTime: 30 seconds (balance data changes frequently) + * - Refetch on window focus: enabled + * - Only runs when address is provided + * + * @example + * ```tsx + * const { data: balances, isLoading, error } = useUserBalancesQuery({ + * address: '0x...', + * networkIds: [SupportedNetworks.Mainnet, SupportedNetworks.Base], + * }); + * ``` + */ +export const useUserBalancesQuery = (options: UseUserBalancesOptions = {}) => { + const { address: connectedAddress } = useConnection(); + const { findToken } = useTokensQuery(); + + const address = options.address ?? connectedAddress; + const networksToFetch = options.networkIds ?? ALL_SUPPORTED_NETWORKS; + const enabled = options.enabled ?? true; + + return useQuery({ + queryKey: ['user-balances', address, networksToFetch], + queryFn: async () => { + if (!address) { + return []; + } + + if (networksToFetch.length === 0) { + return []; + } + + try { + // Fetch balances from specified networks only + const balancePromises = networksToFetch.map(async (chainId) => { + try { + const response = await fetch(`/api/balances?address=${address}&chainId=${chainId}`); + if (!response.ok) { + const errorMessage = await response + .json() + .then((errorData) => (errorData?.error as string | undefined) ?? 'Failed to fetch balances') + .catch(() => 'Failed to fetch balances'); + throw new Error(errorMessage); + } + const data = (await response.json()) as TokenResponse; + return { chainId, tokens: data.tokens }; + } catch (err) { + console.warn(`Failed to fetch balances for chain ${chainId}:`, err); + return { + chainId, + tokens: [], + error: err instanceof Error ? err : new Error('Unknown error occurred'), + }; + } + }); + + const networkResults = await Promise.all(balancePromises); + + // Process and filter tokens + const processedBalances: TokenBalance[] = []; + const failedChainIds: number[] = []; + const errorMessages: string[] = []; + + networkResults.forEach((result) => { + result.tokens.forEach((token) => { + const tokenInfo = findToken(token.address, result.chainId); + if (tokenInfo) { + processedBalances.push({ + address: token.address, + balance: token.balance, + chainId: result.chainId, + decimals: tokenInfo.decimals, + symbol: tokenInfo.symbol, + }); + } + }); + + if (result.error) { + failedChainIds.push(result.chainId); + if (result.error.message) { + errorMessages.push(result.error.message); + } + } + }); + + // Only throw error if ALL networks failed + if (failedChainIds.length > 0 && failedChainIds.length === networksToFetch.length) { + const fallbackMessage = 'All networks failed to fetch balances'; + const aggregatedMessage = errorMessages.length > 0 ? [...new Set(errorMessages)].join(' | ') : fallbackMessage; + throw new Error(aggregatedMessage); + } + + return processedBalances; + } catch (err) { + console.error('Error fetching balances:', err); + throw err instanceof Error ? err : new Error('Unknown error occurred'); + } + }, + enabled: enabled && Boolean(address), + staleTime: 30_000, // 30 seconds - balances change frequently + refetchOnWindowFocus: true, + }); +}; + +/** + * Helper hook to fetch balances from all networks (for backward compatibility). + * + * @example + * ```tsx + * const { data: balances, isLoading, error } = useUserBalancesAllNetworksQuery(); + * ``` + */ +export const useUserBalancesAllNetworksQuery = () => { + return useUserBalancesQuery({ + networkIds: ALL_SUPPORTED_NETWORKS, + }); +}; diff --git a/src/hooks/queries/useUserTransactionsQuery.ts b/src/hooks/queries/useUserTransactionsQuery.ts new file mode 100644 index 00000000..03db9967 --- /dev/null +++ b/src/hooks/queries/useUserTransactionsQuery.ts @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchUserTransactions, type TransactionFilters, type TransactionResponse } from './fetchUserTransactions'; + +/** + * Fetches user transactions from Morpho API or Subgraph using React Query. + * + * Data fetching strategy: + * - Tries Morpho API first (if supported for the network) + * - 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, + * }, + * }); + * ``` + */ +export const useUserTransactionsQuery = (options: { filters: TransactionFilters; enabled?: boolean }) => { + const { filters, enabled = true } = options; + + return useQuery({ + queryKey: [ + 'user-transactions', + filters.userAddress, + filters.marketUniqueKeys, + filters.chainIds, + filters.timestampGte, + filters.timestampLte, + filters.skip, + filters.first, + filters.hash, + filters.assetIds, + ], + queryFn: () => fetchUserTransactions(filters), + enabled: enabled && filters.userAddress.length > 0, + staleTime: 30_000, // 30 seconds - transactions change moderately frequently + refetchOnWindowFocus: true, + }); +}; diff --git a/src/hooks/useUserVaultsV2.ts b/src/hooks/queries/useUserVaultsV2Query.ts similarity index 65% rename from src/hooks/useUserVaultsV2.ts rename to src/hooks/queries/useUserVaultsV2Query.ts index f0c17edb..f6fe1c7a 100644 --- a/src/hooks/useUserVaultsV2.ts +++ b/src/hooks/queries/useUserVaultsV2Query.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; import type { Address } from 'viem'; import { useConnection } from 'wagmi'; import { fetchMorphoMarketV1Adapters } from '@/data-sources/subgraph/morpho-market-v1-adapters'; @@ -8,11 +8,9 @@ import { getMorphoAddress } from '@/utils/morpho'; import { getNetworkConfig } from '@/utils/networks'; import { fetchUserVaultShares } from '@/utils/vaultAllocation'; -type UseUserVaultsV2Return = { - vaults: UserVaultV2[]; - loading: boolean; - error: Error | null; - refetch: () => Promise; +type UseUserVaultsV2Options = { + userAddress?: Address; + enabled?: boolean; }; function filterValidVaults(vaults: UserVaultV2[]): UserVaultV2[] { @@ -83,45 +81,51 @@ async function fetchAndProcessVaults(userAddress: Address): Promise { const { address: connectedAddress } = useConnection(); - const [vaults, setVaults] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - // Use provided account or fall back to connected address - const targetAddress = account ?? connectedAddress; - - const fetchVaults = useCallback(async () => { - if (!targetAddress) { - setVaults([]); - setLoading(false); - return; - } - - setLoading(true); - setError(null); - try { - const vaultsWithBalances = await fetchAndProcessVaults(targetAddress as Address); - setVaults(vaultsWithBalances); - } catch (err) { - const fetchError = err instanceof Error ? err : new Error('Failed to fetch user vaults'); - setError(fetchError); - console.error('Error fetching user V2 vaults:', fetchError); - } finally { - setLoading(false); - } - }, [targetAddress]); - - useEffect(() => { - void fetchVaults(); - }, [fetchVaults]); - - return { - vaults, - loading, - error, - refetch: fetchVaults, - }; -} + const userAddress = (options.userAddress ?? connectedAddress) as Address; + const enabled = options.enabled ?? true; + + return useQuery({ + queryKey: ['user-vaults-v2', userAddress], + queryFn: async () => { + if (!userAddress) { + return []; + } + + try { + return await fetchAndProcessVaults(userAddress); + } catch (err) { + const fetchError = err instanceof Error ? err : new Error('Failed to fetch user vaults'); + console.error('Error fetching user V2 vaults:', fetchError); + throw fetchError; + } + }, + enabled: enabled && Boolean(userAddress), + staleTime: 60_000, // 60 seconds - complex multi-step fetch + refetchOnWindowFocus: true, + }); +}; diff --git a/src/hooks/useAllMorphoVaults.ts b/src/hooks/useAllMorphoVaults.ts deleted file mode 100644 index 9c39e3c9..00000000 --- a/src/hooks/useAllMorphoVaults.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { fetchAllMorphoVaults, type MorphoVault } from '@/data-sources/morpho-api/vaults'; - -type UseAllMorphoVaultsReturn = { - vaults: MorphoVault[]; - loading: boolean; - error: Error | null; - refetch: () => Promise; -}; - -/** - * Hook to fetch all whitelisted Morpho vaults from the API - * Returns vaults with vendor as 'unknown' since API doesn't provide vendor info - */ -export function useAllMorphoVaults(): UseAllMorphoVaultsReturn { - const [vaults, setVaults] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - - try { - const result = await fetchAllMorphoVaults(); - setVaults(result); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch Morpho vaults')); - setVaults([]); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - // Memoize the refetch function to prevent unnecessary re-renders in parent components - const refetch = useCallback(async () => { - await load(); - }, [load]); - - return useMemo( - () => ({ - vaults, - loading, - error, - refetch, - }), - [vaults, error, loading, refetch], - ); -} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts deleted file mode 100644 index 490a2f3f..00000000 --- a/src/hooks/useLocalStorage.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import storage from 'local-storage-fallback'; - -export function useLocalStorage(key: string, initialValue: T): readonly [T, (value: T | ((val: T) => T)) => void] { - // State to store our value - // Always use initialValue during SSR and before hydration - const [storedValue, setStoredValue] = useState(initialValue); - - // Hydrate from localStorage after mount to avoid hydration mismatch - useEffect(() => { - try { - const item = storage.getItem(key); - if (item) { - setStoredValue(JSON.parse(item) as T); - } - } catch (error) { - console.warn(`Error reading localStorage key "${key}":`, error); - } - }, [key]); - - // Return a wrapped version of useState's setter function that persists the new value to localStorage - const setValue = useCallback( - (value: T | ((val: T) => T)) => { - try { - // Allow value to be a function so we have same API as useState - const valueToStore = value instanceof Function ? value(storedValue) : value; - - // Save state - setStoredValue(valueToStore); - - // Save to localStorage only on client - if (typeof window !== 'undefined') { - storage.setItem(key, JSON.stringify(valueToStore)); - } - } catch (error) { - console.warn(`Error setting localStorage key "${key}":`, error); - } - }, - [key, storedValue], - ); - - // Sync updates from other windows - useEffect(() => { - if (typeof window === 'undefined') return; - - const handleStorageChange = (e: StorageEvent) => { - if (e.key === key && e.newValue !== null) { - try { - setStoredValue(JSON.parse(e.newValue) as T); - } catch (error) { - console.warn(`Error parsing localStorage value for key "${key}":`, error); - } - } - }; - - window.addEventListener('storage', handleStorageChange); - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, [key]); - - return [storedValue, setValue] as const; -} diff --git a/src/hooks/usePositionReport.ts b/src/hooks/usePositionReport.ts index e6b81e05..7fe38767 100644 --- a/src/hooks/usePositionReport.ts +++ b/src/hooks/usePositionReport.ts @@ -5,7 +5,7 @@ import { fetchPositionsSnapshots } from '@/utils/positions'; import { estimatedBlockNumber, getClient } from '@/utils/rpc'; import type { Market, MarketPosition, UserTransaction } from '@/utils/types'; import { useCustomRpc } from '@/stores/useCustomRpc'; -import useUserTransactions from './useUserTransactions'; +import { fetchUserTransactions } from './queries/fetchUserTransactions'; export type PositionReport = { market: Market; @@ -36,8 +36,6 @@ export const usePositionReport = ( startDate?: Date, _endDate?: Date, ) => { - const { fetchTransactions } = useUserTransactions(); - const { customRpcUrls } = useCustomRpc(); const generateReport = async (): Promise => { @@ -75,7 +73,7 @@ export const usePositionReport = ( let skip = 0; while (hasMore) { - const transactionResult = await fetchTransactions({ + const transactionResult = await fetchUserTransactions({ userAddress: [account], chainIds: [selectedAsset.chainId], timestampGte: startTimestamp, diff --git a/src/hooks/useUserBalances.ts b/src/hooks/useUserBalances.ts deleted file mode 100644 index 25a7f74e..00000000 --- a/src/hooks/useUserBalances.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { useCallback, useEffect, useState, useMemo } from 'react'; -import { useConnection } from 'wagmi'; -import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; -import { type SupportedNetworks, ALL_SUPPORTED_NETWORKS } from '@/utils/networks'; - -export type TokenBalance = { - address: string; - balance: string; - chainId: number; - decimals: number; - - symbol: string; -}; - -type TokenResponse = { - tokens: { - address: string; - balance: string; - }[]; -}; - -type UseUserBalancesOptions = { - networkIds?: SupportedNetworks[]; -}; - -export function useUserBalances(options: UseUserBalancesOptions = {}) { - const { address } = useConnection(); - const [balances, setBalances] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const { findToken } = useTokensQuery(); - - // Get networks to fetch from - either specified ones or agent-enabled networks - const networksToFetch = useMemo(() => { - return options.networkIds ?? ALL_SUPPORTED_NETWORKS; - }, [options.networkIds]); - - const fetchBalances = useCallback( - async (chainId: number): Promise => { - try { - const response = await fetch(`/api/balances?address=${address}&chainId=${chainId}`); - if (!response.ok) { - const errorMessage = await response - .json() - .then((errorData) => (errorData?.error as string | undefined) ?? 'Failed to fetch balances') - .catch(() => 'Failed to fetch balances'); - throw new Error(errorMessage); - } - const data = (await response.json()) as TokenResponse; - return data.tokens; - } catch (err) { - console.warn(`Failed to fetch balances for chain ${chainId}:`, err); - throw err instanceof Error ? err : new Error('Unknown error occurred'); - } - }, - [address], - ); - - const fetchAllBalances = useCallback(async () => { - if (!address) { - setBalances([]); - setLoading(false); - return; - } - - if (networksToFetch.length === 0) { - setBalances([]); - setLoading(false); - return; - } - - setLoading(true); - setError(null); - - try { - // Fetch balances from specified networks only - const balancePromises = networksToFetch.map(async (chainId) => { - try { - const tokens = await fetchBalances(chainId); - return { chainId, tokens }; - } catch (err) { - return { - chainId, - tokens: [], - error: err instanceof Error ? err : new Error('Unknown error occurred'), - }; - } - }); - - const networkResults = await Promise.all(balancePromises); - - // Process and filter tokens - const processedBalances: TokenBalance[] = []; - const failedChainIds: number[] = []; - const errorMessages: string[] = []; - - const processTokens = (tokens: TokenResponse['tokens'], chainId: number) => { - tokens.forEach((token) => { - const tokenInfo = findToken(token.address, chainId); - if (tokenInfo) { - processedBalances.push({ - address: token.address, - balance: token.balance, - chainId, - decimals: tokenInfo.decimals, - symbol: tokenInfo.symbol, - }); - } - }); - }; - - networkResults.forEach((result) => { - processTokens(result.tokens, result.chainId); - - if (result.error) { - failedChainIds.push(result.chainId); - if (result.error.message) { - errorMessages.push(result.error.message); - } - } - }); - - // Always set balances, even if some/all networks failed - setBalances(processedBalances); - - // Only set error if ALL networks failed - if (failedChainIds.length > 0 && failedChainIds.length === networksToFetch.length) { - const fallbackMessage = 'All networks failed to fetch balances'; - const aggregatedMessage = errorMessages.length > 0 ? [...new Set(errorMessages)].join(' | ') : fallbackMessage; - setError(new Error(aggregatedMessage)); - } - } catch (err) { - setError(err instanceof Error ? err : new Error('Unknown error occurred')); - console.error('Error fetching balances:', err); - } finally { - setLoading(false); - } - }, [address, fetchBalances, networksToFetch]); - - useEffect(() => { - void fetchAllBalances(); - }, [fetchAllBalances]); - - return { - balances, - loading, - error, - refetch: fetchAllBalances, - }; -} - -// Helper function to fetch balances from all networks (for backward compatibility) -export function useUserBalancesAllNetworks() { - return useUserBalances({ - networkIds: ALL_SUPPORTED_NETWORKS, - }); -} diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index a1699720..e4f1d8dc 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -6,9 +6,9 @@ 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 } from '@/utils/types'; +import type { MarketPositionWithEarnings, UserTransaction } from '@/utils/types'; import useUserPositions, { positionKeys } from './useUserPositions'; -import useUserTransactions from './useUserTransactions'; +import { fetchUserTransactions } from './queries/fetchUserTransactions'; export type EarningsPeriod = 'all' | 'day' | 'week' | 'month'; @@ -68,7 +68,6 @@ const fetchPeriodBlockNumbers = async (period: EarningsPeriod, chainIds?: Suppor const useUserPositionsSummaryData = (user: string | undefined, period: EarningsPeriod = 'all', chainIds?: SupportedNetworks[]) => { const { data: positions, loading: positionsLoading, isRefetching, positionsError } = useUserPositions(user, true, chainIds); - const { fetchTransactions } = useUserTransactions(); const queryClient = useQueryClient(); const { customRpcUrls } = useCustomRpcContext(); @@ -141,7 +140,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP // Deduplicate chain IDs to avoid fetching same network multiple times const uniqueChainIds = chainIds ?? [...new Set(positions.map((p) => p.market.morphoBlue.chain.id as SupportedNetworks))]; - const result = await fetchTransactions({ + const result = await fetchUserTransactions({ userAddress: [user], marketUniqueKeys: positions.map((p) => p.market.uniqueKey), chainIds: uniqueChainIds, @@ -183,7 +182,9 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP const pastBalance = pastSnapshot ? BigInt(pastSnapshot.supplyAssets) : 0n; // Filter transactions for this market (case-insensitive comparison) - const marketTxs = (allTransactions ?? []).filter((tx) => tx.data?.market?.uniqueKey?.toLowerCase() === marketIdLower); + const marketTxs = (allTransactions ?? []).filter( + (tx: UserTransaction) => tx.data?.market?.uniqueKey?.toLowerCase() === marketIdLower, + ); // Calculate earnings const earnings = calculateEarningsFromSnapshot(currentBalance, pastBalance, marketTxs, startTimestamp, now); diff --git a/src/hooks/useUserRebalancerInfo.ts b/src/hooks/useUserRebalancerInfo.ts deleted file mode 100644 index 525202d7..00000000 --- a/src/hooks/useUserRebalancerInfo.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { userRebalancerInfoQuery } from '@/graphql/morpho-api-queries'; -import { networks, isAgentAvailable } from '@/utils/networks'; -import type { UserRebalancerInfo } from '@/utils/types'; -import { getMonarchAgentUrl } from '@/utils/urls'; - -/** - * Get monarch v1 rebalancer info - * @param account - * @returns - */ -export function useUserRebalancerInfo(account: string | undefined) { - const [loading, setLoading] = useState(true); - const [data, setData] = useState([]); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - if (!account) { - setLoading(false); - setData([]); - return; - } - - try { - setLoading(true); - setError(null); - - const agentNetworks = networks.filter((network) => isAgentAvailable(network.network)).map((network) => network.network); - - const promises = agentNetworks.map(async (networkId) => { - const apiUrl = getMonarchAgentUrl(networkId); - if (!apiUrl) return null; - - const response = await fetch(apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: userRebalancerInfoQuery, - variables: { id: account.toLowerCase() }, - }), - }); - - const json = (await response.json()) as { - data?: { user?: UserRebalancerInfo }; - }; - - if (json.data?.user) { - return { - ...json.data.user, - network: networkId, - } as UserRebalancerInfo; - } - return null; - }); - - const results = await Promise.all(promises); - const validResults = results.filter((result): result is UserRebalancerInfo => result !== null); - - setData(validResults); - } catch (err) { - console.error('Error fetching rebalancer info:', err); - setError(err); - setData([]); - } finally { - setLoading(false); - } - }, [account]); - - useEffect(() => { - void fetchData(); - }, [fetchData]); - - return { - rebalancerInfos: data, - loading, - error, - refetch: fetchData, - }; -} diff --git a/src/hooks/useUserTransactions.ts b/src/hooks/useUserTransactions.ts deleted file mode 100644 index 912f5a71..00000000 --- a/src/hooks/useUserTransactions.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { useState, useCallback } from 'react'; -import { supportsMorphoApi } from '@/config/dataSources'; -import { fetchMorphoTransactions } from '@/data-sources/morpho-api/transactions'; -import { fetchSubgraphTransactions } from '@/data-sources/subgraph/transactions'; -import { SupportedNetworks, isSupportedChain } from '@/utils/networks'; -import type { UserTransaction } from '@/utils/types'; - -export type TransactionFilters = { - userAddress: string[]; // Expecting only one for subgraph compatibility - marketUniqueKeys?: string[]; // empty: all markets - chainIds?: number[]; // Optional: If provided, fetch only from these chains - timestampGte?: number; - timestampLte?: number; - skip?: number; - first?: number; - hash?: string; - assetIds?: string[]; -}; - -export type TransactionResponse = { - items: UserTransaction[]; - pageInfo: { - count: number; // Count of items *in the current page* after client-side pagination - countTotal: number; // Estimated total count across all sources - }; - error: string | null; -}; - -// Define a default limit for fetching from each source when combining -const MAX_ITEMS_PER_SOURCE = 1000; - -const useUserTransactions = () => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchTransactions = useCallback(async (filters: TransactionFilters): Promise => { - setLoading(true); - setError(null); - - // 1. Determine target networks (numeric enum values) - let targetNetworks: SupportedNetworks[]; - - if (filters.chainIds && filters.chainIds.length > 0) { - // Filter provided chainIds to only include valid, supported numeric values - targetNetworks = filters.chainIds.filter(isSupportedChain) as SupportedNetworks[]; - } else { - // Default to all supported networks (get only numeric values from enum) - targetNetworks = Object.values(SupportedNetworks).filter((value) => typeof value === 'number') as SupportedNetworks[]; - } - - if (targetNetworks.length === 0) { - console.warn('No valid target networks determined.'); - setLoading(false); - return { - items: [], - pageInfo: { count: 0, countTotal: 0 }, - error: null, - }; - } - - // Check for subgraph user address limitation - const usesSubgraph = targetNetworks.some((network) => !supportsMorphoApi(network)); - if (usesSubgraph && filters.userAddress.length !== 1) { - console.error('Subgraph requires exactly one user address.'); - setError('Subgraph data source requires exactly one user address.'); - setLoading(false); - return { - items: [], - pageInfo: { count: 0, countTotal: 0 }, - error: 'Subgraph data source requires exactly one user address.', - }; - } - - // 2. Create fetch promises for each network - const results = await Promise.allSettled( - targetNetworks.map(async (network) => { - let networkItems: UserTransaction[] = []; - let networkError: string | null = null; - - // Try Morpho API first if supported - if (supportsMorphoApi(network)) { - try { - console.log(`Attempting to fetch transactions via Morpho API for network ${network}`); - const morphoFilters = { - ...filters, - chainIds: [network], - first: MAX_ITEMS_PER_SOURCE, - skip: 0, - }; - const morphoResponse = await fetchMorphoTransactions(morphoFilters); - if (!morphoResponse.error) { - networkItems = morphoResponse.items; - console.log(`Received ${networkItems.length} items from Morpho API for network ${network}`); - return { - items: networkItems, - pageInfo: { - count: networkItems.length, - countTotal: networkItems.length, - }, - error: null, - }; - } - networkError = morphoResponse.error; - console.warn(`Error from Morpho API for network ${network}:`, networkError); - } catch (morphoError) { - console.error(`Failed to fetch from Morpho API for network ${network}:`, morphoError); - networkError = `Failed to fetch from Morpho API: ${(morphoError as Error)?.message || 'Unknown error'}`; - } - } - - // Only try Subgraph if Morpho API failed or is not supported - if (!supportsMorphoApi(network) || networkError) { - try { - console.log(`Attempting to fetch transactions via Subgraph for network ${network}`); - const subgraphFilters = { - ...filters, - chainIds: [network], - first: MAX_ITEMS_PER_SOURCE, - skip: 0, - }; - const subgraphResponse = await fetchSubgraphTransactions(subgraphFilters, network); - if (!subgraphResponse.error) { - networkItems = subgraphResponse.items; - console.log(`Received ${networkItems.length} items from Subgraph for network ${network}`); - return { - items: networkItems, - pageInfo: { - count: networkItems.length, - countTotal: networkItems.length, - }, - error: null, - }; - } - networkError = subgraphResponse.error; - console.warn(`Error from Subgraph for network ${network}:`, networkError); - } catch (subgraphError) { - console.error(`Failed to fetch from Subgraph for network ${network}:`, subgraphError); - networkError = `Failed to fetch from Subgraph: ${(subgraphError as Error)?.message || 'Unknown error'}`; - } - } - - // Only reach here if both Morpho API and Subgraph failed - return { - items: networkItems, - pageInfo: { - count: networkItems.length, - countTotal: networkItems.length, - }, - error: networkError, - }; - }), - ); - - // 4. Combine results - let combinedItems: UserTransaction[] = []; - let combinedTotalCount = 0; - const errors: string[] = []; - - results.forEach((result) => { - if (result.status === 'fulfilled') { - const response = result.value; - if (response.error) { - errors.push(response.error); - } else { - combinedItems = combinedItems.concat(response.items); - combinedTotalCount += response.pageInfo.countTotal; - } - } else { - errors.push(`Failed to fetch transactions: ${result.reason?.message || 'Unknown error'}`); - } - }); - - // 5. Sort combined results by timestamp - combinedItems.sort((a, b) => b.timestamp - a.timestamp); - - // 6. Apply client-side pagination - const skip = filters.skip ?? 0; - const first = filters.first ?? combinedItems.length; - const paginatedItems = combinedItems.slice(skip, skip + first); - - const finalError = errors.length > 0 ? errors.join('; ') : null; - if (finalError) { - setError(finalError); - } - - setLoading(false); - - return { - items: paginatedItems, - pageInfo: { - count: paginatedItems.length, - countTotal: combinedTotalCount, - }, - error: finalError, - }; - }, []); - - return { - loading, - error, - fetchTransactions, - }; -}; - -export default useUserTransactions; diff --git a/src/hooks/useVaultAllocations.ts b/src/hooks/useVaultAllocations.ts index 452d17cd..05659752 100644 --- a/src/hooks/useVaultAllocations.ts +++ b/src/hooks/useVaultAllocations.ts @@ -5,7 +5,7 @@ import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { parseCapIdParams } from '@/utils/morpho'; import type { SupportedNetworks } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; -import { useAllocations } from './useAllocations'; +import { useAllocationsQuery } from './queries/useAllocations'; import { useProcessedMarkets } from './useProcessedMarkets'; import { useVaultV2Data } from './useVaultV2Data'; @@ -108,7 +108,7 @@ export function useVaultAllocations({ vaultAddress, chainId, enabled = true }: U const allValidCaps = useMemo(() => [...validCollateralCaps, ...validMarketCaps], [validCollateralCaps, validMarketCaps]); // Fetch allocations only for valid, recognized caps - const { allocations, isLoading, error, refetch } = useAllocations({ + const { allocations, isLoading, error, refetch } = useAllocationsQuery({ vaultAddress, chainId, caps: allValidCaps, diff --git a/src/modals/settings/trusted-vaults-modal.tsx b/src/modals/settings/trusted-vaults-modal.tsx index f0ced006..daf022c9 100644 --- a/src/modals/settings/trusted-vaults-modal.tsx +++ b/src/modals/settings/trusted-vaults-modal.tsx @@ -13,7 +13,7 @@ import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/ import { NetworkIcon } from '@/components/shared/network-icon'; import { VaultIdentity } from '@/features/autovault/components/vault-identity'; import { known_vaults, type KnownVault, type TrustedVault } from '@/constants/vaults/known_vaults'; -import { useAllMorphoVaults } from '@/hooks/useAllMorphoVaults'; +import { useAllMorphoVaultsQuery } from '@/hooks/queries/useAllMorphoVaultsQuery'; import { useTrustedVaults } from '@/stores/useTrustedVaults'; type TrustedVaultsModalProps = { @@ -27,7 +27,7 @@ export default function TrustedVaultsModal({ isOpen, onOpenChange }: TrustedVaul const [morphoSectionOpen, setMorphoSectionOpen] = useState(false); // Fetch all Morpho vaults from API - const { vaults: morphoVaults, loading: morphoLoading } = useAllMorphoVaults(); + const { data: morphoVaults = [], isLoading: morphoLoading } = useAllMorphoVaultsQuery(); // Transform Morpho API vaults to TrustedVault format const morphoWhitelistedVaults = useMemo(() => { diff --git a/src/stores/useTransactionFilters.ts b/src/stores/useTransactionFilters.ts index c5bf8008..e004d06b 100644 --- a/src/stores/useTransactionFilters.ts +++ b/src/stores/useTransactionFilters.ts @@ -71,7 +71,6 @@ export const useTransactionFiltersStore = create()( /** * Convenience hook with scoped API for a specific token symbol. - * Maintains backward-compatible interface with the old useLocalStorage-based hook. * * FIX: Use separate selectors for primitives to avoid infinite loop from object creation. * The `??` operator with object literal creates a new reference every render! From 4beb25eea42c5571913f8895d14f464a6967e242 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 11:38:05 +0800 Subject: [PATCH 2/4] chore: fix build --- src/data-sources/morpho-api/transactions.ts | 2 +- src/data-sources/subgraph/transactions.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 349c9d1f..348b58d1 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -1,5 +1,5 @@ import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; -import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/useUserTransactionsQuery'; +import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/fetchUserTransactions'; import { SupportedNetworks } from '@/utils/networks'; import { morphoGraphqlFetcher } from './fetchers'; diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts index 95700433..0188b0b1 100644 --- a/src/data-sources/subgraph/transactions.ts +++ b/src/data-sources/subgraph/transactions.ts @@ -1,5 +1,5 @@ import { subgraphUserTransactionsQuery } from '@/graphql/morpho-subgraph-queries'; -import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/useUserTransactionsQuery'; +import type { TransactionFilters, TransactionResponse } from '@/hooks/queries/fetchUserTransactions'; import type { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; import { type UserTransaction, UserTxTypes } from '@/utils/types'; From 3edf9a01ab758e129e75c26f180358e5cbb0668a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 11:54:18 +0800 Subject: [PATCH 3/4] chore: review changes --- docs/ARCHITECTURE.md | 2 +- src/hooks/useUserPositionsSummaryData.ts | 31 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 105e4336..c5720b30 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,7 +185,7 @@ Monarch allow users to choose which Morpho vaults they trust, and use them as fi **Registry**: `src/contexts/VaultRegistryContext.tsx` -**Hook**: `useAllMorphoVaults()` in `src/hooks/useAllMorphoVaults.ts` +**Hook**: `useAllMorphoVaultsQuery()` in `src/hooks/queries/useAllMorphoVaultsQuery.ts` - Fetches all vaults from Morpho API - Caches with TanStack Query diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index e4f1d8dc..90c4b927 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -132,13 +132,26 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP }); // Query for all transactions (independent of period) - const { data: allTransactions, isLoading: isLoadingTransactions } = useQuery({ - queryKey: ['user-transactions-summary', user, positionsKey, chainIds?.join(',') ?? 'all'], - queryFn: async () => { - if (!positions || !user) return []; + const uniqueChainIds = useMemo( + () => chainIds ?? [...new Set(positions?.map((p) => p.market.morphoBlue.chain.id as SupportedNetworks) ?? [])], + [chainIds, positions], + ); - // Deduplicate chain IDs to avoid fetching same network multiple times - const uniqueChainIds = chainIds ?? [...new Set(positions.map((p) => p.market.morphoBlue.chain.id as SupportedNetworks))]; + const { data: transactionResponse, isLoading: isLoadingTransactions } = useQuery({ + queryKey: [ + 'user-transactions', + user ? [user] : [], + positions?.map((p) => p.market.uniqueKey), + uniqueChainIds, + undefined, // timestampGte + undefined, // timestampLte + undefined, // skip + undefined, // first + undefined, // hash + undefined, // assetIds + ], + queryFn: async () => { + if (!positions || !user) return { items: [], pageInfo: { count: 0, countTotal: 0 }, error: null }; const result = await fetchUserTransactions({ userAddress: [user], @@ -146,13 +159,15 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP chainIds: uniqueChainIds, }); - return result?.items ?? []; + 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 []; @@ -211,7 +226,7 @@ const useUserPositionsSummaryData = (user: string | undefined, period: EarningsP }); // Invalidate transactions await queryClient.invalidateQueries({ - queryKey: ['user-transactions-summary', user], + queryKey: ['user-transactions', user ? [user] : []], }); onSuccess?.(); From 693271685215fe42890052c9d2254f1861177602 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Mon, 29 Dec 2025 12:10:45 +0800 Subject: [PATCH 4/4] misc: state --- src/features/history/components/history-table.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/features/history/components/history-table.tsx b/src/features/history/components/history-table.tsx index 9c072e2a..bc07c63b 100644 --- a/src/features/history/components/history-table.tsx +++ b/src/features/history/components/history-table.tsx @@ -116,7 +116,6 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His const history = data?.items ?? []; const totalPages = data ? Math.ceil(data.pageInfo.countTotal / pageSize) : 0; - const isInitialized = !loading; // Group transactions for display (especially useful for vault adapter mode) const groupedHistory = useMemo(() => groupTransactionsByHash(history), [history]); @@ -623,7 +622,7 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His - {!isInitialized || loading ? ( + {loading ? ( renderSkeletonRows(8) ) : (isGroupedView ? groupedHistory : ungroupedHistory).length === 0 ? ( @@ -981,7 +980,7 @@ export function HistoryTable({ account, positions, isVaultAdapter = false }: His - {isInitialized && !loading && totalPages > 1 && ( + {!loading && totalPages > 1 && (