From ecf0433cb5361fff9b699049668e88b3fc1ab711 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 23 Apr 2025 15:11:01 +0800 Subject: [PATCH 01/20] chore: add subgraph alternative for supplies and borrows table --- .../[marketid]/components/BorrowsTable.tsx | 20 +-- .../[marketid]/components/SuppliesTable.tsx | 20 +-- src/config/dataSources.ts | 4 +- src/data-sources/morpho-api/market-borrows.ts | 64 +++++++++ .../morpho-api/market-supplies.ts | 69 +++++++++ src/data-sources/subgraph/market-borrows.ts | 84 +++++++++++ src/data-sources/subgraph/market-supplies.ts | 98 +++++++++++++ src/graphql/morpho-subgraph-queries.ts | 66 +++++++++ src/hooks/useMarketBorrows.ts | 124 +++++++--------- src/hooks/useMarketSupplies.ts | 132 ++++++++---------- src/utils/types.ts | 9 ++ 11 files changed, 529 insertions(+), 161 deletions(-) create mode 100644 src/data-sources/morpho-api/market-borrows.ts create mode 100644 src/data-sources/morpho-api/market-supplies.ts create mode 100644 src/data-sources/subgraph/market-borrows.ts create mode 100644 src/data-sources/subgraph/market-supplies.ts diff --git a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx index b2288d1f..073eaab4 100644 --- a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx @@ -8,7 +8,7 @@ import { formatUnits } from 'viem'; import AccountWithAvatar from '@/components/Account/AccountWithAvatar'; import { Badge } from '@/components/common/Badge'; import { TokenIcon } from '@/components/TokenIcon'; -import useMarketBorrows from '@/hooks/useMarketBorrows'; +import { useMarketBorrows } from '@/hooks/useMarketBorrows'; import { getExplorerURL, getExplorerTxURL } from '@/utils/external'; import { Market } from '@/utils/types'; @@ -26,7 +26,11 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; - const { borrows, loading, error } = useMarketBorrows(market?.uniqueKey); + const { data: borrows, isLoading, error } = useMarketBorrows( + market?.uniqueKey, + market.loanAsset.id, + chainId, + ); const totalPages = Math.ceil((borrows || []).length / pageSize); @@ -42,7 +46,7 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { const tableKey = `borrows-table-${currentPage}`; if (error) { - return

Error loading borrows: {error}

; + return

Error loading borrows: {error instanceof Error ? error.message : 'Unknown error'}

; } return ( @@ -82,19 +86,19 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { {paginatedBorrows.map((borrow) => ( - + @@ -104,7 +108,7 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { - {formatUnits(BigInt(borrow.data.assets), market.loanAsset.decimals)} + {formatUnits(BigInt(borrow.amount), market.loanAsset.decimals)} {market?.loanAsset?.symbol && ( Error loading supplies: {error}

; - } - return (

Supply & Withdraw

@@ -82,19 +82,19 @@ export function SuppliesTable({ chainId, market }: SuppliesTableProps) { {paginatedSupplies.map((supply) => ( - + @@ -104,7 +104,7 @@ export function SuppliesTable({ chainId, market }: SuppliesTableProps) { - {formatUnits(BigInt(supply.data.assets), market.loanAsset.decimals)} + {formatUnits(BigInt(supply.amount), market.loanAsset.decimals)} {market?.loanAsset?.symbol && ( { switch (network) { // case SupportedNetworks.Mainnet: - // case SupportedNetworks.Base: - // return 'subgraph'; + case SupportedNetworks.Base: + return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/data-sources/morpho-api/market-borrows.ts b/src/data-sources/morpho-api/market-borrows.ts new file mode 100644 index 00000000..32b9919d --- /dev/null +++ b/src/data-sources/morpho-api/market-borrows.ts @@ -0,0 +1,64 @@ +import { marketBorrowsQuery } from '@/graphql/morpho-api-queries'; +// Import the shared type from its new location +import { MarketActivityTransaction } from '@/utils/types'; +import { morphoGraphqlFetcher } from './fetchers'; + +// Type specifically for the raw Morpho API response structure for borrows/repays +type MorphoAPIBorrowsResponse = { + data?: { + transactions?: { + items?: Array<{ + type: 'MarketBorrow' | 'MarketRepay'; // Specific types for this query + hash: string; + timestamp: number; + data: { + assets: string; + shares: string; // Present but ignored in unified type + }; + user: { + address: string; + }; + }>; + }; + }; +}; + +/** + * Fetches market borrow/repay activities from the Morpho Blue API. + * Uses the shared Morpho API fetcher. + * @param marketId The unique key or ID of the market. + * @returns A promise resolving to an array of unified MarketActivityTransaction objects. + */ +export const fetchMorphoMarketBorrows = async ( + marketId: string, +): Promise => { + const variables = { + uniqueKey: marketId, + first: 1000, + skip: 0, + }; + + try { + const result = await morphoGraphqlFetcher( + marketBorrowsQuery, + variables, + ); + + const items = result.data?.transactions?.items ?? []; + + // Map to unified type (reusing MarketActivityTransaction) + return items.map((item) => ({ + type: item.type, // Directly use 'MarketBorrow' or 'MarketRepay' + hash: item.hash, + timestamp: item.timestamp, + amount: item.data.assets, // Map 'assets' to 'amount' + userAddress: item.user.address, + })); + } catch (error) { + console.error(`Error fetching or processing Morpho API market borrows for ${marketId}:`, error); + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching Morpho API market borrows'); + } +}; \ No newline at end of file diff --git a/src/data-sources/morpho-api/market-supplies.ts b/src/data-sources/morpho-api/market-supplies.ts new file mode 100644 index 00000000..5384c677 --- /dev/null +++ b/src/data-sources/morpho-api/market-supplies.ts @@ -0,0 +1,69 @@ +import { marketSuppliesQuery } from '@/graphql/morpho-api-queries'; +import { MarketActivityTransaction } from '@/utils/types'; +import { morphoGraphqlFetcher } from './fetchers'; // Import shared fetcher + +// Type specifically for the raw Morpho API response structure within this module +type MorphoAPISuppliesResponse = { + data?: { // Mark data as optional to align with fetcher's generic handling + transactions?: { + items?: Array<{ + type: 'MarketSupply' | 'MarketWithdraw'; + hash: string; + timestamp: number; + data: { + assets: string; + shares: string; + }; + user: { + address: string; + }; + }>; + }; + }; + // Error handling is now done by the fetcher +}; + +/** + * Fetches market supply/withdraw activities from the Morpho Blue API. + * Uses the shared Morpho API fetcher. + * @param marketId The unique key or ID of the market. + * @returns A promise resolving to an array of unified MarketActivityTransaction objects. + */ +export const fetchMorphoMarketSupplies = async ( + marketId: string, +): Promise => { + const variables = { + uniqueKey: marketId, // Ensure this matches the variable name in the query + first: 1000, + skip: 0, + }; + + try { + // Use the shared fetcher + const result = await morphoGraphqlFetcher( + marketSuppliesQuery, + variables, + ); + + // Fetcher handles network and basic GraphQL errors + const items = result.data?.transactions?.items ?? []; + + // Map to unified type + return items.map((item) => ({ + type: item.type, + hash: item.hash, + timestamp: item.timestamp, + amount: item.data.assets, + userAddress: item.user.address, + // Note: 'shares' from Morpho API is omitted in the unified type + })); + } catch (error) { + // Catch errors from the fetcher or during processing + console.error(`Error fetching or processing Morpho API market supplies for ${marketId}:`, error); + // Re-throw the error to be handled by the calling hook + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching Morpho API market supplies'); + } +}; \ No newline at end of file diff --git a/src/data-sources/subgraph/market-borrows.ts b/src/data-sources/subgraph/market-borrows.ts new file mode 100644 index 00000000..7eecafb7 --- /dev/null +++ b/src/data-sources/subgraph/market-borrows.ts @@ -0,0 +1,84 @@ +import { marketBorrowsRepaysQuery } from '@/graphql/morpho-subgraph-queries'; +import { MarketActivityTransaction } from '@/utils/types'; // Import shared type +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { subgraphGraphqlFetcher } from './fetchers'; + +// Types specific to the Subgraph response for this query +type SubgraphBorrowRepayItem = { + amount: string; + account: { + id: string; + }; + timestamp: number | string; + hash: string; +}; + +type SubgraphBorrowsRepaysResponse = { + data?: { + borrows?: SubgraphBorrowRepayItem[]; + repays?: SubgraphBorrowRepayItem[]; + }; +}; + +/** + * Fetches market borrow/repay activities from the Subgraph. + * @param marketId The ID of the market. + * @param loanAssetId The address of the loan asset. + * @param network The blockchain network. + * @returns A promise resolving to an array of unified MarketActivityTransaction objects. + */ +export const fetchSubgraphMarketBorrows = async ( + marketId: string, + loanAssetId: string, + network: SupportedNetworks, +): Promise => { + const subgraphUrl = getSubgraphUrl(network); + if (!subgraphUrl) { + console.error(`No Subgraph URL configured for network: ${network}`); + throw new Error(`Subgraph URL not available for network ${network}`); + } + + const variables = { marketId, loanAssetId }; + + try { + const result = await subgraphGraphqlFetcher( + subgraphUrl, + marketBorrowsRepaysQuery, + variables, + ); + + const borrows = result.data?.borrows ?? []; + const repays = result.data?.repays ?? []; + + // Map borrows to the unified type + const mappedBorrows: MarketActivityTransaction[] = borrows.map((b) => ({ + type: 'MarketBorrow', + hash: b.hash, + timestamp: typeof b.timestamp === 'string' ? parseInt(b.timestamp, 10) : b.timestamp, + amount: b.amount, + userAddress: b.account.id, + })); + + // Map repays to the unified type + const mappedRepays: MarketActivityTransaction[] = repays.map((r) => ({ + type: 'MarketRepay', + hash: r.hash, + timestamp: typeof r.timestamp === 'string' ? parseInt(r.timestamp, 10) : r.timestamp, + amount: r.amount, + userAddress: r.account.id, + })); + + // Combine and sort by timestamp descending + const combined = [...mappedBorrows, ...mappedRepays]; + combined.sort((a, b) => b.timestamp - a.timestamp); + + return combined; + } catch (error) { + console.error(`Error fetching or processing Subgraph market borrows for ${marketId}:`, error); + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching subgraph market borrows'); + } +}; \ No newline at end of file diff --git a/src/data-sources/subgraph/market-supplies.ts b/src/data-sources/subgraph/market-supplies.ts new file mode 100644 index 00000000..8e9c1334 --- /dev/null +++ b/src/data-sources/subgraph/market-supplies.ts @@ -0,0 +1,98 @@ +import { marketDepositsWithdrawsQuery } from '@/graphql/morpho-subgraph-queries'; +import { MarketActivityTransaction } from '@/utils/types'; +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; // Import shared utility +import { subgraphGraphqlFetcher } from './fetchers'; // Import shared fetcher + +// Types specific to the Subgraph response for this query +type SubgraphSupplyWithdrawItem = { + amount: string; + account: { + id: string; + }; + timestamp: number | string; // Allow string timestamp from subgraph + hash: string; +}; + +type SubgraphSuppliesWithdrawsResponse = { + data?: { + deposits?: SubgraphSupplyWithdrawItem[]; + withdraws?: SubgraphSupplyWithdrawItem[]; + }; + // Error handling is now done by the fetcher +}; + +/** + * Fetches market supply/withdraw activities (deposits/withdraws of loan asset) from the Subgraph. + * Uses the shared subgraph fetcher and URL utility. + * @param marketId The ID of the market. + * @param loanAssetId The address of the loan asset. + * @param network The blockchain network. + * @returns A promise resolving to an array of unified MarketActivityTransaction objects. + */ +export const fetchSubgraphMarketSupplies = async ( + marketId: string, + loanAssetId: string, + network: SupportedNetworks, +): Promise => { + const subgraphUrl = getSubgraphUrl(network); + if (!subgraphUrl) { + // Error handling for missing URL remains important + console.error(`No Subgraph URL configured for network: ${network}`); + throw new Error(`Subgraph URL not available for network ${network}`); + } + + const variables = { + marketId, // Ensure these match the types expected by the Subgraph query (e.g., Bytes) + loanAssetId, + }; + + try { + // Use the shared fetcher + const result = await subgraphGraphqlFetcher( + subgraphUrl, + marketDepositsWithdrawsQuery, + variables, + ); + + // Fetcher handles network and basic GraphQL errors, proceed with data processing + const deposits = result.data?.deposits ?? []; + const withdraws = result.data?.withdraws ?? []; + + // Map deposits and withdraws to the unified type + const mappedDeposits: MarketActivityTransaction[] = deposits.map((d) => ({ + type: 'MarketSupply', + hash: d.hash, + // Ensure timestamp is treated as a number + timestamp: typeof d.timestamp === 'string' ? parseInt(d.timestamp, 10) : d.timestamp, + amount: d.amount, + userAddress: d.account.id, + })); + + const mappedWithdraws: MarketActivityTransaction[] = withdraws.map((w) => ({ + type: 'MarketWithdraw', + hash: w.hash, + timestamp: typeof w.timestamp === 'string' ? parseInt(w.timestamp, 10) : w.timestamp, + amount: w.amount, + userAddress: w.account.id, + })); + + // Combine and sort by timestamp descending (most recent first) + const combined = [...mappedDeposits, ...mappedWithdraws]; + + console.log('combined', combined.length) + + combined.sort((a, b) => b.timestamp - a.timestamp); + + return combined; + } catch (error) { + // Catch errors from the fetcher or during processing + console.error(`Error fetching or processing Subgraph market supplies for ${marketId}:`, error); + // Re-throw the error to be handled by the calling hook (useQuery) + // Ensuring the error object is an instance of Error + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching subgraph market supplies'); + } +}; \ No newline at end of file diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 98d0d332..f625a346 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -145,3 +145,69 @@ export const marketHourlySnapshotsQuery = ` ${tokenFragment} # Ensure TokenFields fragment is included `; // --- End Added Section --- + +// --- Query for Market Supplies/Withdraws (Deposits/Withdraws of Loan Asset) --- +export const marketDepositsWithdrawsQuery = ` + query getMarketDepositsWithdraws($marketId: Bytes!, $loanAssetId: Bytes!) { + deposits( + first: 1000, # Subgraph max limit + orderBy: timestamp, + orderDirection: desc, + where: { market: $marketId, asset: $loanAssetId } + ) { + amount + account { + id + } + timestamp + hash + } + withdraws( + first: 1000, # Subgraph max limit + orderBy: timestamp, + orderDirection: desc, + where: { market: $marketId, asset: $loanAssetId } + ) { + amount + account { + id + } + timestamp + hash + } + } +`; +// --- End Query --- + +// --- Query for Market Borrows/Repays (Borrows/Repays of Loan Asset) --- +export const marketBorrowsRepaysQuery = ` + query getMarketBorrowsRepays($marketId: Bytes!, $loanAssetId: Bytes!) { + borrows( + first: 1000, + orderBy: timestamp, + orderDirection: desc, + where: { market: $marketId, asset: $loanAssetId } + ) { + amount + account { + id + } + timestamp + hash + } + repays( + first: 1000, + orderBy: timestamp, + orderDirection: desc, + where: { market: $marketId, asset: $loanAssetId } + ) { + amount + account { + id + } + timestamp + hash + } + } +`; +// --- End Query --- diff --git a/src/hooks/useMarketBorrows.ts b/src/hooks/useMarketBorrows.ts index f45279f0..c83d1e23 100644 --- a/src/hooks/useMarketBorrows.ts +++ b/src/hooks/useMarketBorrows.ts @@ -1,86 +1,68 @@ -import { useState, useEffect, useCallback } from 'react'; -import { marketBorrowsQuery } from '@/graphql/morpho-api-queries'; -import { URLS } from '@/utils/urls'; - -export type MarketBorrowTransaction = { - type: 'MarketBorrow' | 'MarketRepay'; - hash: string; - timestamp: number; - data: { - assets: string; - shares: string; - }; - user: { - address: string; - }; -}; +import { useQuery } from '@tanstack/react-query'; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoMarketBorrows } from '@/data-sources/morpho-api/market-borrows'; +import { fetchSubgraphMarketBorrows } from '@/data-sources/subgraph/market-borrows'; +import { SupportedNetworks } from '@/utils/networks'; +import { MarketActivityTransaction } from '@/utils/types'; /** - * Hook to fetch all borrow and repay activities for a specific market - * @param marketUniqueKey The unique key of the market - * @returns List of all borrow and repay transactions for the market + * Hook to fetch all borrow and repay activities for a specific market's loan asset, + * using the appropriate data source based on the network. + * @param marketId The ID or unique key of the market. + * @param loanAssetId The address of the loan asset for the market. + * @param network The blockchain network. + * @returns List of borrow and repay transactions for the market's loan asset. */ -const useMarketBorrows = (marketUniqueKey: string | undefined) => { - const [borrows, setBorrows] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchBorrows = useCallback(async () => { - if (!marketUniqueKey) { - setBorrows([]); - return; - } - - setLoading(true); - setError(null); - - try { - const variables = { - uniqueKey: marketUniqueKey, - first: 1000, // Limit to 100 most recent transactions - skip: 0, - }; +export const useMarketBorrows = ( + marketId: string | undefined, + loanAssetId: string | undefined, + network: SupportedNetworks | undefined, +) => { + const queryKey = ['marketBorrows', marketId, loanAssetId, network]; - const response = await fetch(`${URLS.MORPHO_BLUE_API}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: marketBorrowsQuery, - variables, - }), - }); + // Determine the data source + const dataSource = network ? getMarketDataSource(network) : null; - if (!response.ok) { - throw new Error('Failed to fetch market borrows'); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: queryKey, + queryFn: async (): Promise => { + // Guard clauses + if (!marketId || !loanAssetId || !network || !dataSource) { + return null; } - const result = (await response.json()) as { - data: { transactions: { items: MarketBorrowTransaction[] } }; - }; + console.log( + `Fetching market borrows for market ${marketId} (loan asset ${loanAssetId}) on ${network} via ${dataSource}`, + ); - if (result.data?.transactions?.items) { - setBorrows(result.data.transactions.items); - } else { - setBorrows([]); + try { + if (dataSource === 'morpho') { + // Morpho API might only need marketId for borrows + return await fetchMorphoMarketBorrows(marketId); + } else if (dataSource === 'subgraph') { + return await fetchSubgraphMarketBorrows(marketId, loanAssetId, network); + } + } catch (fetchError) { + console.error(`Failed to fetch market borrows via ${dataSource}:`, fetchError); + return null; } - } catch (err) { - console.error('Error fetching market borrows:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, [marketUniqueKey]); - useEffect(() => { - void fetchBorrows(); - }, [fetchBorrows]); + console.warn('Unknown market data source determined for borrows'); + return null; + }, + enabled: !!marketId && !!loanAssetId && !!network && !!dataSource, + staleTime: 1000 * 60 * 2, // 2 minutes + placeholderData: (previousData) => previousData ?? null, + retry: 1, + }); + // Return react-query result structure return { - borrows, - loading, - error, + data: data, + isLoading: isLoading, + error: error, + refetch: refetch, + dataSource: dataSource, }; }; diff --git a/src/hooks/useMarketSupplies.ts b/src/hooks/useMarketSupplies.ts index 6fb21cd3..acfedd48 100644 --- a/src/hooks/useMarketSupplies.ts +++ b/src/hooks/useMarketSupplies.ts @@ -1,87 +1,79 @@ -import { useState, useEffect, useCallback } from 'react'; -import { marketSuppliesQuery } from '@/graphql/morpho-api-queries'; -import { URLS } from '@/utils/urls'; - -export type MarketSupplyTransaction = { - type: 'MarketSupply' | 'MarketWithdraw'; - hash: string; - timestamp: number; - data: { - assets: string; - shares: string; - }; - user: { - address: string; - }; -}; +import { useQuery } from '@tanstack/react-query'; +import { getMarketDataSource } from '@/config/dataSources'; +import { SupportedNetworks } from '@/utils/networks'; +import { fetchMorphoMarketSupplies } from '@/data-sources/morpho-api/market-supplies'; +import { fetchSubgraphMarketSupplies } from '@/data-sources/subgraph/market-supplies'; +import { MarketActivityTransaction } from '@/utils/types'; /** - * Hook to fetch all supply and withdraw activities for a specific market - * @param marketUniqueKey The unique key of the market - * @returns List of all supply and withdraw transactions for the market + * Hook to fetch all supply and withdraw activities for a specific market's loan asset, + * using the appropriate data source based on the network. + * @param marketId The ID of the market (e.g., 0x...). + * @param loanAssetId The address of the loan asset for the market. + * @param network The blockchain network. + * @returns List of supply and withdraw transactions for the market's loan asset. */ -const useMarketSupplies = (marketUniqueKey: string | undefined) => { - const [supplies, setSupplies] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchSupplies = useCallback(async () => { - if (!marketUniqueKey) { - setSupplies([]); - return; - } - - setLoading(true); - setError(null); +export const useMarketSupplies = ( + marketId: string | undefined, + loanAssetId: string | undefined, + network: SupportedNetworks | undefined, +) => { + const queryKey = ['marketSupplies', marketId, loanAssetId, network]; - try { - const variables = { - uniqueKey: marketUniqueKey, - first: 1000, // Limit to 100 most recent transactions - skip: 0, - }; + // Determine the data source + const dataSource = network ? getMarketDataSource(network) : null; - const response = await fetch(`${URLS.MORPHO_BLUE_API}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: marketSuppliesQuery, - variables, - }), - }); + console.log('dataSource', dataSource) - if (!response.ok) { - throw new Error('Failed to fetch market supplies'); + const { data, isLoading, error, refetch } = useQuery< + MarketActivityTransaction[] | null // The hook returns the unified type + >({ + queryKey: queryKey, + queryFn: async (): Promise => { + // Guard clauses + if (!marketId || !loanAssetId || !network || !dataSource) { + return null; } - const result = (await response.json()) as { - data: { transactions: { items: MarketSupplyTransaction[] } }; - }; + console.log( + `Fetching market supplies for market ${marketId} (loan asset ${loanAssetId}) on ${network} via ${dataSource}`, + ); - if (result.data?.transactions?.items) { - setSupplies(result.data.transactions.items); - } else { - setSupplies([]); + try { + // Call the appropriate imported function + if (dataSource === 'morpho') { + return await fetchMorphoMarketSupplies(marketId); + } else if (dataSource === 'subgraph') { + return await fetchSubgraphMarketSupplies(marketId, loanAssetId, network); + } + } catch (fetchError) { + // Log the specific error from the data source function + console.error( + `Failed to fetch market supplies via ${dataSource} for market ${marketId}:`, + fetchError, + ); + return null; // Return null on fetch error } - } catch (err) { - console.error('Error fetching market supplies:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, [marketUniqueKey]); - useEffect(() => { - void fetchSupplies(); - }, [fetchSupplies]); + // This case should ideally not be reached if getMarketDataSource is exhaustive + console.warn('Unknown market data source determined for supplies'); + return null; + }, + // enable query only if all parameters are present AND a valid data source exists + enabled: !!marketId && !!loanAssetId && !!network && !!dataSource, + staleTime: 1000 * 60 * 2, // 2 minutes + placeholderData: (previousData) => previousData ?? null, + retry: 1, + }); return { - supplies, - loading, - error, + data: data, + isLoading: isLoading, + error: error, + refetch: refetch, + dataSource: dataSource, }; }; +// Keep export default for potential existing imports, but prefer named export export default useMarketSupplies; diff --git a/src/utils/types.ts b/src/utils/types.ts index 3c1b6a58..e234defc 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -366,3 +366,12 @@ export type AgentMetadata = { name: string; strategyDescription: string; }; + +// Define the comprehensive Market Activity Transaction type +export type MarketActivityTransaction = { + type: 'MarketSupply' | 'MarketWithdraw' | 'MarketBorrow' | 'MarketRepay'; + hash: string; + timestamp: number; + amount: string; // Unified field for assets/amount + userAddress: string; // Unified field for user address +}; From 41c0160930da1fe94cfd66ac3535b2ebb45a7115 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 23 Apr 2025 15:24:19 +0800 Subject: [PATCH 02/20] feat: finish borrow and supply --- .../[marketid]/components/BorrowsTable.tsx | 20 +++++++++++-------- .../[marketid]/components/SuppliesTable.tsx | 4 ++-- src/data-sources/morpho-api/market-borrows.ts | 6 +++--- .../morpho-api/market-supplies.ts | 14 ++++++++----- src/data-sources/subgraph/market-borrows.ts | 4 ++-- src/data-sources/subgraph/market-supplies.ts | 6 ++---- src/hooks/useMarketSupplies.ts | 4 +--- 7 files changed, 31 insertions(+), 27 deletions(-) diff --git a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx index 073eaab4..8869107f 100644 --- a/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/BorrowsTable.tsx @@ -26,27 +26,31 @@ export function BorrowsTable({ chainId, market }: BorrowsTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; - const { data: borrows, isLoading, error } = useMarketBorrows( - market?.uniqueKey, - market.loanAsset.id, - chainId, - ); + const { + data: borrows, + isLoading, + error, + } = useMarketBorrows(market?.uniqueKey, market.loanAsset.id, chainId); - const totalPages = Math.ceil((borrows || []).length / pageSize); + const totalPages = Math.ceil((borrows ?? []).length / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; const paginatedBorrows = useMemo(() => { - const sliced = (borrows || []).slice((currentPage - 1) * pageSize, currentPage * pageSize); + const sliced = (borrows ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); return sliced; }, [currentPage, borrows, pageSize]); const tableKey = `borrows-table-${currentPage}`; if (error) { - return

Error loading borrows: {error instanceof Error ? error.message : 'Unknown error'}

; + return ( +

+ Error loading borrows: {error instanceof Error ? error.message : 'Unknown error'} +

+ ); } return ( diff --git a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx index 1e9d58f8..d09b96fc 100644 --- a/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx +++ b/app/market/[chainId]/[marketid]/components/SuppliesTable.tsx @@ -32,14 +32,14 @@ export function SuppliesTable({ chainId, market }: SuppliesTableProps) { chainId, ); - const totalPages = Math.ceil((supplies || []).length / pageSize); + const totalPages = Math.ceil((supplies ?? []).length / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; const paginatedSupplies = useMemo(() => { - const sliced = (supplies || []).slice((currentPage - 1) * pageSize, currentPage * pageSize); + const sliced = (supplies ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); return sliced; }, [currentPage, supplies, pageSize]); diff --git a/src/data-sources/morpho-api/market-borrows.ts b/src/data-sources/morpho-api/market-borrows.ts index 32b9919d..7923a5f9 100644 --- a/src/data-sources/morpho-api/market-borrows.ts +++ b/src/data-sources/morpho-api/market-borrows.ts @@ -7,7 +7,7 @@ import { morphoGraphqlFetcher } from './fetchers'; type MorphoAPIBorrowsResponse = { data?: { transactions?: { - items?: Array<{ + items?: { type: 'MarketBorrow' | 'MarketRepay'; // Specific types for this query hash: string; timestamp: number; @@ -18,7 +18,7 @@ type MorphoAPIBorrowsResponse = { user: { address: string; }; - }>; + }[]; }; }; }; @@ -61,4 +61,4 @@ export const fetchMorphoMarketBorrows = async ( } throw new Error('An unknown error occurred while fetching Morpho API market borrows'); } -}; \ No newline at end of file +}; diff --git a/src/data-sources/morpho-api/market-supplies.ts b/src/data-sources/morpho-api/market-supplies.ts index 5384c677..72b71494 100644 --- a/src/data-sources/morpho-api/market-supplies.ts +++ b/src/data-sources/morpho-api/market-supplies.ts @@ -4,9 +4,10 @@ import { morphoGraphqlFetcher } from './fetchers'; // Import shared fetcher // Type specifically for the raw Morpho API response structure within this module type MorphoAPISuppliesResponse = { - data?: { // Mark data as optional to align with fetcher's generic handling + data?: { + // Mark data as optional to align with fetcher's generic handling transactions?: { - items?: Array<{ + items?: { type: 'MarketSupply' | 'MarketWithdraw'; hash: string; timestamp: number; @@ -17,7 +18,7 @@ type MorphoAPISuppliesResponse = { user: { address: string; }; - }>; + }[]; }; }; // Error handling is now done by the fetcher @@ -59,11 +60,14 @@ export const fetchMorphoMarketSupplies = async ( })); } catch (error) { // Catch errors from the fetcher or during processing - console.error(`Error fetching or processing Morpho API market supplies for ${marketId}:`, error); + console.error( + `Error fetching or processing Morpho API market supplies for ${marketId}:`, + error, + ); // Re-throw the error to be handled by the calling hook if (error instanceof Error) { throw error; } throw new Error('An unknown error occurred while fetching Morpho API market supplies'); } -}; \ No newline at end of file +}; diff --git a/src/data-sources/subgraph/market-borrows.ts b/src/data-sources/subgraph/market-borrows.ts index 7eecafb7..7a1e3a82 100644 --- a/src/data-sources/subgraph/market-borrows.ts +++ b/src/data-sources/subgraph/market-borrows.ts @@ -1,7 +1,7 @@ import { marketBorrowsRepaysQuery } from '@/graphql/morpho-subgraph-queries'; -import { MarketActivityTransaction } from '@/utils/types'; // Import shared type import { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { MarketActivityTransaction } from '@/utils/types'; // Import shared type import { subgraphGraphqlFetcher } from './fetchers'; // Types specific to the Subgraph response for this query @@ -81,4 +81,4 @@ export const fetchSubgraphMarketBorrows = async ( } throw new Error('An unknown error occurred while fetching subgraph market borrows'); } -}; \ No newline at end of file +}; diff --git a/src/data-sources/subgraph/market-supplies.ts b/src/data-sources/subgraph/market-supplies.ts index 8e9c1334..c2641ad9 100644 --- a/src/data-sources/subgraph/market-supplies.ts +++ b/src/data-sources/subgraph/market-supplies.ts @@ -1,7 +1,7 @@ import { marketDepositsWithdrawsQuery } from '@/graphql/morpho-subgraph-queries'; -import { MarketActivityTransaction } from '@/utils/types'; import { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; // Import shared utility +import { MarketActivityTransaction } from '@/utils/types'; import { subgraphGraphqlFetcher } from './fetchers'; // Import shared fetcher // Types specific to the Subgraph response for this query @@ -80,8 +80,6 @@ export const fetchSubgraphMarketSupplies = async ( // Combine and sort by timestamp descending (most recent first) const combined = [...mappedDeposits, ...mappedWithdraws]; - console.log('combined', combined.length) - combined.sort((a, b) => b.timestamp - a.timestamp); return combined; @@ -95,4 +93,4 @@ export const fetchSubgraphMarketSupplies = async ( } throw new Error('An unknown error occurred while fetching subgraph market supplies'); } -}; \ No newline at end of file +}; diff --git a/src/hooks/useMarketSupplies.ts b/src/hooks/useMarketSupplies.ts index acfedd48..1debc146 100644 --- a/src/hooks/useMarketSupplies.ts +++ b/src/hooks/useMarketSupplies.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { getMarketDataSource } from '@/config/dataSources'; -import { SupportedNetworks } from '@/utils/networks'; import { fetchMorphoMarketSupplies } from '@/data-sources/morpho-api/market-supplies'; import { fetchSubgraphMarketSupplies } from '@/data-sources/subgraph/market-supplies'; +import { SupportedNetworks } from '@/utils/networks'; import { MarketActivityTransaction } from '@/utils/types'; /** @@ -23,8 +23,6 @@ export const useMarketSupplies = ( // Determine the data source const dataSource = network ? getMarketDataSource(network) : null; - console.log('dataSource', dataSource) - const { data, isLoading, error, refetch } = useQuery< MarketActivityTransaction[] | null // The hook returns the unified type >({ From 7edea6e1e0d905482bd3ccbd9ed59b6340a78f2c Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 23 Apr 2025 16:12:04 +0800 Subject: [PATCH 03/20] refactor useMarketLiquidations --- .../components/LiquidationsTable.tsx | 72 ++++++----- src/config/dataSources.ts | 4 +- src/data-sources/morpho-api/historical.ts | 11 -- .../morpho-api/market-liquidations.ts | 68 ++++++++++ .../subgraph/market-liquidations.ts | 91 ++++++++++++++ src/graphql/morpho-subgraph-queries.ts | 31 +++++ src/hooks/useMarketHistoricalData.ts | 8 +- src/hooks/useMarketLiquidations.ts | 119 ++++++++---------- src/utils/types.ts | 11 ++ 9 files changed, 294 insertions(+), 121 deletions(-) create mode 100644 src/data-sources/morpho-api/market-liquidations.ts create mode 100644 src/data-sources/subgraph/market-liquidations.ts diff --git a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx index 7d32d629..9e41b924 100644 --- a/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx +++ b/app/market/[chainId]/[marketid]/components/LiquidationsTable.tsx @@ -6,9 +6,9 @@ import moment from 'moment'; import { Address, formatUnits } from 'viem'; import AccountWithAvatar from '@/components/Account/AccountWithAvatar'; import { TokenIcon } from '@/components/TokenIcon'; -import useMarketLiquidations from '@/hooks/useMarketLiquidations'; +import { useMarketLiquidations } from '@/hooks/useMarketLiquidations'; import { getExplorerTxURL, getExplorerURL } from '@/utils/external'; -import { Market } from '@/utils/types'; +import { Market, MarketLiquidationTransaction } from '@/utils/types'; // Helper functions to format data const formatAddress = (address: string) => { @@ -24,23 +24,31 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { const [currentPage, setCurrentPage] = useState(1); const pageSize = 8; - const { liquidations, loading, error } = useMarketLiquidations(market?.uniqueKey); + const { + data: liquidations, + isLoading, + error, + } = useMarketLiquidations(market?.uniqueKey, chainId); - const totalPages = Math.ceil((liquidations || []).length / pageSize); + const totalPages = Math.ceil((liquidations ?? []).length / pageSize); const handlePageChange = (page: number) => { setCurrentPage(page); }; const paginatedLiquidations = useMemo(() => { - const sliced = (liquidations || []).slice((currentPage - 1) * pageSize, currentPage * pageSize); + const sliced = (liquidations ?? []).slice((currentPage - 1) * pageSize, currentPage * pageSize); return sliced; }, [currentPage, liquidations, pageSize]); const tableKey = `liquidations-table-${currentPage}`; if (error) { - return

Error loading liquidations: {error}

; + return ( +

+ Error loading liquidations: {error instanceof Error ? error.message : 'Unknown error'} +

+ ); } return ( @@ -71,14 +79,11 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { > LIQUIDATOR - REPAID ({market?.loanAsset?.symbol ?? 'USDC'}) + REPAID ({market?.loanAsset?.symbol ?? 'Loan'}) - SEIZED{' '} - {market?.collateralAsset?.symbol && ( - {market.collateralAsset.symbol} - )} + SEIZED ({market?.collateralAsset?.symbol ?? 'Collateral'}) - BAD DEBT + BAD DEBT ({market?.loanAsset?.symbol ?? 'Loan'}) TIME TRANSACTION @@ -86,27 +91,32 @@ export function LiquidationsTable({ chainId, market }: LiquidationsTableProps) { - {paginatedLiquidations.map((liquidation) => { - const hasBadDebt = BigInt(liquidation.data.badDebtAssets) !== BigInt(0); + {paginatedLiquidations.map((liquidation: MarketLiquidationTransaction) => { + const hasBadDebt = BigInt(liquidation.badDebtAssets) !== BigInt(0); + const isLiquidatorAddress = liquidation.liquidator?.startsWith('0x'); return ( - - - - + {isLiquidatorAddress ? ( + + + + + ) : ( + {liquidation.liquidator} + )} - {formatUnits(BigInt(liquidation.data.repaidAssets), market.loanAsset.decimals)} + {formatUnits(BigInt(liquidation.repaidAssets), market.loanAsset.decimals)} {market?.loanAsset?.symbol && ( - {formatUnits( - BigInt(liquidation.data.seizedAssets), - market.collateralAsset.decimals, - )} + {formatUnits(BigInt(liquidation.seizedAssets), market.collateralAsset.decimals)} {market?.collateralAsset?.symbol && ( {hasBadDebt ? ( <> - {formatUnits( - BigInt(liquidation.data.badDebtAssets), - market.loanAsset.decimals, - )} + {formatUnits(BigInt(liquidation.badDebtAssets), market.loanAsset.decimals)} {market?.loanAsset?.symbol && ( { switch (network) { // case SupportedNetworks.Mainnet: - case SupportedNetworks.Base: - return 'subgraph'; + // case SupportedNetworks.Base: + // return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/data-sources/morpho-api/historical.ts b/src/data-sources/morpho-api/historical.ts index ec37ee3e..02a2bf35 100644 --- a/src/data-sources/morpho-api/historical.ts +++ b/src/data-sources/morpho-api/historical.ts @@ -51,17 +51,6 @@ export const fetchMorphoMarketHistoricalData = async ( const historicalState = response?.data?.marketByUniqueKey?.historicalState; - // --- Add detailed logging --- - console.log( - '[fetchMorphoMarketHistoricalData] Raw API Response:', - JSON.stringify(response, null, 2), - ); - console.log( - '[fetchMorphoMarketHistoricalData] Extracted historicalState:', - JSON.stringify(historicalState, null, 2), - ); - // --- End logging --- - // Check if historicalState exists and has *any* relevant data points (e.g., supplyApy) // This check might need refinement based on what fields are essential if ( diff --git a/src/data-sources/morpho-api/market-liquidations.ts b/src/data-sources/morpho-api/market-liquidations.ts new file mode 100644 index 00000000..3505887a --- /dev/null +++ b/src/data-sources/morpho-api/market-liquidations.ts @@ -0,0 +1,68 @@ +import { marketLiquidationsQuery } from '@/graphql/morpho-api-queries'; +import { MarketLiquidationTransaction } from '@/utils/types'; // Import unified type +import { morphoGraphqlFetcher } from './fetchers'; + +// Type for the raw Morpho API response structure +type MorphoAPILiquidationItem = { + hash: string; + timestamp: number; + type: string; // Should be 'MarketLiquidation' + data: { + repaidAssets: string; + seizedAssets: string; + liquidator: string; + badDebtAssets: string; + }; +}; + +type MorphoAPILiquidationsResponse = { + data?: { + transactions?: { + items?: MorphoAPILiquidationItem[]; + }; + }; +}; + +/** + * Fetches market liquidation activities from the Morpho Blue API. + * @param marketId The unique key or ID of the market. + * @returns A promise resolving to an array of unified MarketLiquidationTransaction objects. + */ +export const fetchMorphoMarketLiquidations = async ( + marketId: string, +): Promise => { + const variables = { + uniqueKey: marketId, + // Morpho API query might not need first/skip for liquidations, adjust if needed + }; + + try { + const result = await morphoGraphqlFetcher( + marketLiquidationsQuery, + variables, + ); + + const items = result.data?.transactions?.items ?? []; + + // Map to unified type + return items.map((item) => ({ + type: 'MarketLiquidation', // Standardize type + hash: item.hash, + timestamp: item.timestamp, + liquidator: item.data.liquidator, + repaidAssets: item.data.repaidAssets, + seizedAssets: item.data.seizedAssets, + badDebtAssets: item.data.badDebtAssets, + // Removed optional fields not present in the simplified type + })); + } catch (error) { + console.error( + `Error fetching or processing Morpho API market liquidations for ${marketId}:`, + error, + ); + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching Morpho API market liquidations'); + } +}; diff --git a/src/data-sources/subgraph/market-liquidations.ts b/src/data-sources/subgraph/market-liquidations.ts new file mode 100644 index 00000000..44ad3176 --- /dev/null +++ b/src/data-sources/subgraph/market-liquidations.ts @@ -0,0 +1,91 @@ +import { marketLiquidationsAndBadDebtQuery } from '@/graphql/morpho-subgraph-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { MarketLiquidationTransaction } from '@/utils/types'; // Import simplified type +import { subgraphGraphqlFetcher } from './fetchers'; + +// Types specific to the Subgraph response items +type SubgraphLiquidateItem = { + id: string; + hash: string; + timestamp: number | string; + repaid: string; + amount: string; + liquidator: { + id: string; + }; +}; + +type SubgraphBadDebtItem = { + badDebt: string; + liquidation: { + id: string; + }; +}; + +// Type for the overall Subgraph response +type SubgraphLiquidationsResponse = { + data?: { + liquidates?: SubgraphLiquidateItem[]; + badDebtRealizations?: SubgraphBadDebtItem[]; + }; +}; + +/** + * Fetches market liquidation activities from the Subgraph. + * Combines liquidation events with associated bad debt realizations. + * @param marketId The ID of the market. + * @param network The blockchain network. + * @returns A promise resolving to an array of simplified MarketLiquidationTransaction objects. + */ +export const fetchSubgraphMarketLiquidations = async ( + marketId: string, + network: SupportedNetworks, +): Promise => { + const subgraphUrl = getSubgraphUrl(network); + if (!subgraphUrl) { + console.error(`No Subgraph URL configured for network: ${network}`); + throw new Error(`Subgraph URL not available for network ${network}`); + } + + const variables = { marketId }; + + try { + const result = await subgraphGraphqlFetcher( + subgraphUrl, + marketLiquidationsAndBadDebtQuery, + variables, + ); + + const liquidates = result.data?.liquidates ?? []; + const badDebtItems = result.data?.badDebtRealizations ?? []; + + // Create a map for quick lookup of bad debt by liquidation ID + const badDebtMap = new Map(); + badDebtItems.forEach((item) => { + badDebtMap.set(item.liquidation.id, item.badDebt); + }); + + // Map liquidations, adding bad debt information + return liquidates.map((liq) => ({ + type: 'MarketLiquidation', + hash: liq.hash, + timestamp: typeof liq.timestamp === 'string' ? parseInt(liq.timestamp, 10) : liq.timestamp, + // Subgraph query doesn't provide liquidator, use empty string or default + liquidator: liq.liquidator.id, + repaidAssets: liq.repaid, // Loan asset repaid + seizedAssets: liq.amount, // Collateral seized + // Fetch bad debt from the map using the liquidate event ID + badDebtAssets: badDebtMap.get(liq.id) ?? '0', // Default to '0' if no bad debt entry + })); + } catch (error) { + console.error( + `Error fetching or processing Subgraph market liquidations for ${marketId}:`, + error, + ); + if (error instanceof Error) { + throw error; + } + throw new Error('An unknown error occurred while fetching subgraph market liquidations'); + } +}; diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index f625a346..675b673d 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -211,3 +211,34 @@ export const marketBorrowsRepaysQuery = ` } `; // --- End Query --- + +// --- Query for Market Liquidations and Bad Debt --- +export const marketLiquidationsAndBadDebtQuery = ` + query getMarketLiquidations($marketId: Bytes!) { + liquidates( + first: 1000, + where: { market: $marketId }, + orderBy: timestamp, + orderDirection: desc + ) { + id # ID of the liquidate event itself + hash + timestamp + repaid # Amount of loan asset repaid + amount # Amount of collateral seized + liquidator { + id + } + } + badDebtRealizations( + first: 1000, + where: { market: $marketId } + ) { + badDebt + liquidation { + id + } + } + } +`; +// --- End Query --- diff --git a/src/hooks/useMarketHistoricalData.ts b/src/hooks/useMarketHistoricalData.ts index ad33446a..f1310465 100644 --- a/src/hooks/useMarketHistoricalData.ts +++ b/src/hooks/useMarketHistoricalData.ts @@ -40,13 +40,9 @@ export const useMarketHistoricalData = ( console.log(`Fetching historical data for ${uniqueKey} on ${network} via ${dataSource}`); if (dataSource === 'morpho') { - const res = await fetchMorphoMarketHistoricalData(uniqueKey, network, options); - console.log('res morpho', res); - return res; + return fetchMorphoMarketHistoricalData(uniqueKey, network, options); } else if (dataSource === 'subgraph') { - const res = await fetchSubgraphMarketHistoricalData(uniqueKey, network, options); - console.log('res', res); - return res; + return fetchSubgraphMarketHistoricalData(uniqueKey, network, options); } console.warn('Unknown historical data source determined'); diff --git a/src/hooks/useMarketLiquidations.ts b/src/hooks/useMarketLiquidations.ts index 2fd6cfe6..65da8d64 100644 --- a/src/hooks/useMarketLiquidations.ts +++ b/src/hooks/useMarketLiquidations.ts @@ -1,83 +1,66 @@ -import { useState, useEffect, useCallback } from 'react'; -import { marketLiquidationsQuery } from '@/graphql/morpho-api-queries'; -import { URLS } from '@/utils/urls'; - -export type MarketLiquidationTransaction = { - hash: string; - timestamp: number; - type: string; - data: { - repaidAssets: string; - seizedAssets: string; - liquidator: string; - badDebtAssets: string; - }; -}; +import { useQuery } from '@tanstack/react-query'; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoMarketLiquidations } from '@/data-sources/morpho-api/market-liquidations'; +import { fetchSubgraphMarketLiquidations } from '@/data-sources/subgraph/market-liquidations'; +import { SupportedNetworks } from '@/utils/networks'; +import { MarketLiquidationTransaction } from '@/utils/types'; // Use simplified type /** - * Hook to fetch all liquidations for a specific market - * @param marketUniqueKey The unique key of the market - * @returns List of all liquidation transactions for the market + * Hook to fetch all liquidations for a specific market, using the appropriate data source. + * @param marketId The ID or unique key of the market. + * @param network The blockchain network. + * @returns List of liquidation transactions for the market. */ -const useMarketLiquidations = (marketUniqueKey: string | undefined) => { - const [liquidations, setLiquidations] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchLiquidations = useCallback(async () => { - if (!marketUniqueKey) { - setLiquidations([]); - return; - } - - setLoading(true); - setError(null); - - try { - const variables = { - uniqueKey: marketUniqueKey, - }; +export const useMarketLiquidations = ( + marketId: string | undefined, + network: SupportedNetworks | undefined, +) => { + // Note: loanAssetId is not needed for liquidations query + const queryKey = ['marketLiquidations', marketId, network]; - const response = await fetch(`${URLS.MORPHO_BLUE_API}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: marketLiquidationsQuery, - variables, - }), - }); + // Determine the data source + const dataSource = network ? getMarketDataSource(network) : null; - if (!response.ok) { - throw new Error('Failed to fetch market liquidations'); + const { data, isLoading, error, refetch } = useQuery({ + queryKey: queryKey, + queryFn: async (): Promise => { + // Guard clauses + if (!marketId || !network || !dataSource) { + return null; } - const result = (await response.json()) as { - data: { transactions: { items: MarketLiquidationTransaction[] } }; - }; + console.log( + `Fetching market liquidations for market ${marketId} on ${network} via ${dataSource}`, + ); - if (result.data?.transactions?.items) { - setLiquidations(result.data.transactions.items); - } else { - setLiquidations([]); + try { + if (dataSource === 'morpho') { + return await fetchMorphoMarketLiquidations(marketId); + } else if (dataSource === 'subgraph') { + console.log('fetching subgraph liquidations'); + return await fetchSubgraphMarketLiquidations(marketId, network); + } + } catch (fetchError) { + console.error(`Failed to fetch market liquidations via ${dataSource}:`, fetchError); + return null; } - } catch (err) { - console.error('Error fetching market liquidations:', err); - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, [marketUniqueKey]); - useEffect(() => { - void fetchLiquidations(); - }, [fetchLiquidations]); + console.warn('Unknown market data source determined for liquidations'); + return null; + }, + enabled: !!marketId && !!network && !!dataSource, + staleTime: 1000 * 60 * 5, // 5 minutes, liquidations are less frequent + placeholderData: (previousData) => previousData ?? null, + retry: 1, + }); + // Return standard react-query hook structure return { - liquidations, - loading, - error, + data: data, // Consumers can alias this as 'liquidations' if desired + isLoading: isLoading, + error: error, + refetch: refetch, + dataSource: dataSource, }; }; diff --git a/src/utils/types.ts b/src/utils/types.ts index e234defc..6b272562 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -375,3 +375,14 @@ export type MarketActivityTransaction = { amount: string; // Unified field for assets/amount userAddress: string; // Unified field for user address }; + +// Type for Liquidation Transactions (Simplified based on original hook) +export type MarketLiquidationTransaction = { + type: 'MarketLiquidation'; + hash: string; + timestamp: number; + liquidator: string; + repaidAssets: string; + seizedAssets: string; + badDebtAssets: string; +}; From e322703b95b21198d1bd7e7c844a3e0fc0b3c169 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 25 Apr 2025 14:09:58 +0800 Subject: [PATCH 04/20] chore: lint --- src/config/dataSources.ts | 4 +- src/contexts/MarketsContext.tsx | 114 ++++++++++++++++--------- src/data-sources/morpho-api/market.ts | 35 +++++++- src/data-sources/subgraph/market.ts | 53 +++++++++++- src/graphql/morpho-subgraph-queries.ts | 13 ++- src/hooks/useUserPosition.ts | 2 - src/utils/subgraph-types.ts | 2 +- 7 files changed, 175 insertions(+), 48 deletions(-) diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts index 35064c1e..6c0935f8 100644 --- a/src/config/dataSources.ts +++ b/src/config/dataSources.ts @@ -6,8 +6,8 @@ import { SupportedNetworks } from '@/utils/networks'; export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { switch (network) { // case SupportedNetworks.Mainnet: - // case SupportedNetworks.Base: - // return 'subgraph'; + case SupportedNetworks.Base: + return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index ca90ee33..df78d4b9 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -9,9 +9,11 @@ import { useState, useMemo, } from 'react'; -import { marketsQuery } from '@/graphql/morpho-api-queries'; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoMarkets } from '@/data-sources/morpho-api/market'; +import { fetchSubgraphMarkets } from '@/data-sources/subgraph/market'; import useLiquidations from '@/hooks/useLiquidations'; -import { isSupportedChain } from '@/utils/networks'; +import { isSupportedChain, SupportedNetworks } from '@/utils/networks'; import { Market } from '@/utils/types'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; @@ -30,14 +32,6 @@ type MarketsProviderProps = { children: ReactNode; }; -type MarketResponse = { - data: { - markets: { - items: Market[]; - }; - }; -}; - export function MarketsProvider({ children }: MarketsProviderProps) { const [loading, setLoading] = useState(true); const [isRefetching, setIsRefetching] = useState(false); @@ -53,46 +47,78 @@ export function MarketsProvider({ children }: MarketsProviderProps) { const fetchMarkets = useCallback( async (isRefetch = false) => { - try { - if (isRefetch) { - setIsRefetching(true); - } else { - setLoading(true); - } + if (isRefetch) { + setIsRefetching(true); + } else { + setLoading(true); + } + setError(null); // Reset error at the start - const marketsResponse = await fetch('https://blue-api.morpho.org/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: marketsQuery, - variables: { first: 1000, where: { whitelisted: true } }, - }), - }); + // Define the networks to fetch markets for + const networksToFetch: SupportedNetworks[] = [ + SupportedNetworks.Mainnet, + SupportedNetworks.Base, + ]; + let combinedMarkets: Market[] = []; + let fetchErrors: unknown[] = []; - const marketsResult = (await marketsResponse.json()) as MarketResponse; - const rawMarkets = marketsResult.data.markets.items; + try { + // Fetch markets for each network based on its data source + await Promise.all( + networksToFetch.map(async (network) => { + try { + const dataSource = getMarketDataSource(network); + let networkMarkets: Market[] = []; + + console.log(`Fetching markets for ${network} via ${dataSource}`); + + if (dataSource === 'morpho') { + networkMarkets = await fetchMorphoMarkets(network); + } else if (dataSource === 'subgraph') { + networkMarkets = await fetchSubgraphMarkets(network); + } else { + console.warn(`No valid data source found for network ${network}`); + } + combinedMarkets.push(...networkMarkets); + } catch (networkError) { + console.error(`Failed to fetch markets for network ${network}:`, networkError); + fetchErrors.push(networkError); // Collect errors for each network + } + }), + ); - const filtered = rawMarkets + // Process combined markets (filters, warnings, liquidation status) + // Existing filters seem appropriate + const filtered = combinedMarkets .filter((market) => market.collateralAsset != undefined) .filter( (market) => market.warnings.find((w) => w.type === 'not_whitelisted') === undefined, ) - .filter((market) => isSupportedChain(market.morphoBlue.chain.id)); + .filter((market) => isSupportedChain(market.morphoBlue.chain.id)); // Keep this filter const processedMarkets = filtered.map((market) => { - const warningsWithDetail = getMarketWarningsWithDetail(market); + const warningsWithDetail = getMarketWarningsWithDetail(market); // Recalculate warnings if needed, though fetchers might do this const isProtectedByLiquidationBots = liquidatedMarketKeys.has(market.uniqueKey); return { ...market, - warningsWithDetail, + // Ensure warningsWithDetail from fetchers are used or recalculated consistently + warningsWithDetail: market.warningsWithDetail ?? warningsWithDetail, isProtectedByLiquidationBots, }; }); setMarkets(processedMarkets); - } catch (_error) { - setError(_error); + + // If any network fetch failed, set the overall error state + if (fetchErrors.length > 0) { + // Maybe combine errors or just take the first one + setError(fetchErrors[0]); + } + } catch (err) { + // Catch potential errors from Promise.all itself or overall logic + console.error('Overall error fetching markets:', err); + setError(err); } finally { if (isRefetch) { setIsRefetching(false); @@ -101,19 +127,26 @@ export function MarketsProvider({ children }: MarketsProviderProps) { } } }, - [liquidatedMarketKeys], + [liquidatedMarketKeys], // Dependencies: liquidatedMarketKeys is needed for processing ); useEffect(() => { if (!liquidationsLoading && markets.length === 0) { + // Fetch markets only if liquidations are loaded and markets aren't already populated fetchMarkets().catch(console.error); } - }, [liquidationsLoading, fetchMarkets]); + // Dependency on fetchMarkets is correct here, also depends on liquidationsLoading + }, [liquidationsLoading, fetchMarkets, markets.length]); const refetch = useCallback( - (onSuccess?: () => void) => { - refetchLiquidations(); - fetchMarkets(true).then(onSuccess).catch(console.error); + async (onSuccess?: () => void) => { + try { + refetchLiquidations(); + await fetchMarkets(true); + onSuccess?.(); + } catch (err) { + console.error('Error during refetch:', err); + } }, [refetchLiquidations, fetchMarkets], ); @@ -121,12 +154,17 @@ export function MarketsProvider({ children }: MarketsProviderProps) { const refresh = useCallback(async () => { setLoading(true); setMarkets([]); + setError(null); try { + refetchLiquidations(); await fetchMarkets(); } catch (_error) { console.error('Failed to refresh markets:', _error); + setError(_error); + } finally { + setLoading(false); } - }, [fetchMarkets]); + }, [refetchLiquidations, fetchMarkets]); const isLoading = loading || liquidationsLoading; const combinedError = error || liquidationsError; diff --git a/src/data-sources/morpho-api/market.ts b/src/data-sources/morpho-api/market.ts index c28e2ef9..6e85c9ad 100644 --- a/src/data-sources/morpho-api/market.ts +++ b/src/data-sources/morpho-api/market.ts @@ -1,4 +1,4 @@ -import { marketDetailQuery } from '@/graphql/morpho-api-queries'; +import { marketDetailQuery, marketsQuery } from '@/graphql/morpho-api-queries'; import { SupportedNetworks } from '@/utils/networks'; import { Market } from '@/utils/types'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; @@ -11,6 +11,16 @@ type MarketGraphQLResponse = { errors?: { message: string }[]; }; +// Define response type for multiple markets +type MarketsGraphQLResponse = { + data: { + markets: { + items: Market[]; + }; + }; + errors?: { message: string }[]; +}; + const processMarketData = (market: Market): Market => { const warningsWithDetail = getMarketWarningsWithDetail(market); return { @@ -34,3 +44,26 @@ export const fetchMorphoMarket = async ( } return processMarketData(response.data.marketByUniqueKey); }; + +// Fetcher for multiple markets from Morpho API +export const fetchMorphoMarkets = async (network: SupportedNetworks): Promise => { + // Construct the full variables object including the where clause + const variables = { + first: 1000, // Max limit + where: { + chainId_in: [network], + whitelisted: true, + // Add other potential filters to 'where' if needed in the future + }, + }; + + const response = await morphoGraphqlFetcher(marketsQuery, variables); + + if (!response.data || !response.data.markets || !response.data.markets.items) { + console.warn(`Market data not found in Morpho API response for network ${network}.`); + return []; // Return empty array if not found + } + + // Process each market in the items array + return response.data.markets.items.map(processMarketData); +}; diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 30c86c15..746a31be 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -1,7 +1,15 @@ import { Address } from 'viem'; -import { marketQuery as subgraphMarketQuery } from '@/graphql/morpho-subgraph-queries'; // Assuming query is here +import { + marketQuery as subgraphMarketQuery, + marketsQuery as subgraphMarketsQuery, +} from '@/graphql/morpho-subgraph-queries'; // Assuming query is here import { SupportedNetworks } from '@/utils/networks'; -import { SubgraphMarket, SubgraphMarketQueryResponse, SubgraphToken } from '@/utils/subgraph-types'; +import { + SubgraphMarket, + SubgraphMarketQueryResponse, + SubgraphMarketsQueryResponse, + SubgraphToken, +} from '@/utils/subgraph-types'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; import { WarningWithDetail, MorphoChainlinkOracleData, Market } from '@/utils/types'; import { subgraphGraphqlFetcher } from './fetchers'; @@ -154,3 +162,44 @@ export const fetchSubgraphMarket = async ( return transformSubgraphMarketToMarket(marketData, network); }; + +// Fetcher for multiple markets from Subgraph +export const fetchSubgraphMarkets = async ( + network: SupportedNetworks, + // Optional filter, adjust based on actual subgraph schema capabilities + // filter?: { [key: string]: any }, +): Promise => { + const subgraphApiUrl = getSubgraphUrl(network); + + if (!subgraphApiUrl) { + console.error(`Subgraph URL for network ${network} is not defined.`); + throw new Error(`Subgraph URL for network ${network} is not defined.`); + } + + // Construct variables for the query + const variables: { first: number; where?: Record; network?: string } = { + first: 1000, // Max limit + // If filtering is needed and supported by the schema, add it here + // where: filter, + // Pass network if the query uses it for filtering + // network: network === SupportedNetworks.Base ? 'BASE' : 'MAINNET', // Example mapping + }; + + // Use the new marketsQuery + const response = await subgraphGraphqlFetcher( // Use the new response type + subgraphApiUrl, + subgraphMarketsQuery, // Use the new query + variables, + ); + + // Assuming the response structure matches the single market query for the list + const marketsData = response.data.markets; // Adjust based on actual response structure + + if (!marketsData || !Array.isArray(marketsData)) { + console.warn(`No markets found or invalid format in Subgraph response for network ${network}.`); + return []; // Return empty array if no markets or error + } + + // Transform each market + return marketsData.map((market) => transformSubgraphMarketToMarket(market, network)); +}; diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 675b673d..fd62e944 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -73,8 +73,17 @@ export const marketFragment = ` `; export const marketsQuery = ` - query getSubgraphMarkets($first: Int, $where: Market_filter) { - markets(first: $first, where: $where, orderBy: totalValueLockedUSD, orderDirection: desc) { + query getSubgraphMarkets($first: Int, $where: Market_filter, $network: String) { + markets( + first: $first, + where: $where, + orderBy: totalValueLockedUSD, + orderDirection: desc, + # Subgraph network filtering is typically done via the endpoint URL or a field in the 'where' clause + # Assuming the schema allows filtering by protocol network name: + # where: { protocol_: { network: $network }, ...$where } # Adjust if schema differs + # If filtering by network isn't directly supported in 'where', it might need to be handled post-fetch or by endpoint selection + ) { ...SubgraphMarketFields } } diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index a92de336..6572fa6a 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -55,8 +55,6 @@ const useUserPositions = ( // Read on-chain data const currentSnapshot = await fetchPositionSnapshot(marketKey, user as Address, chainId, 0); - console.log('currentSnapshot', currentSnapshot); - if (currentSnapshot) { setPosition({ market: data.data.marketPosition.market, diff --git a/src/utils/subgraph-types.ts b/src/utils/subgraph-types.ts index 99be139c..e2313cb3 100644 --- a/src/utils/subgraph-types.ts +++ b/src/utils/subgraph-types.ts @@ -79,7 +79,7 @@ export type SubgraphMarketsQueryResponse = { // Type for a single market response (if we adapt query later) export type SubgraphMarketQueryResponse = { data: { - market: SubgraphMarket | null; // Assuming a query like market(id: ...) might return null + market: SubgraphMarket | null; }; errors?: { message: string }[]; }; From 555b14a9c09ca2a5dd701d7a8a75272acbb14076 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 25 Apr 2025 17:20:43 +0800 Subject: [PATCH 05/20] fix: calculation and blacklist --- src/config/dataSources.ts | 3 +- src/contexts/MarketsContext.tsx | 5 +++ src/data-sources/subgraph/market.ts | 49 ++++++++++++++++++-------- src/graphql/morpho-subgraph-queries.ts | 5 +-- src/utils/subgraph-types.ts | 46 +++++++++++++----------- src/utils/tokens.ts | 4 +++ src/utils/types.ts | 4 +-- 7 files changed, 74 insertions(+), 42 deletions(-) diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts index 6c0935f8..f8e55e8c 100644 --- a/src/config/dataSources.ts +++ b/src/config/dataSources.ts @@ -5,7 +5,8 @@ import { SupportedNetworks } from '@/utils/networks'; */ export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { switch (network) { - // case SupportedNetworks.Mainnet: + case SupportedNetworks.Mainnet: + return 'subgraph'; case SupportedNetworks.Base: return 'subgraph'; default: diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index df78d4b9..bb4c17b6 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -79,6 +79,11 @@ export function MarketsProvider({ children }: MarketsProviderProps) { } else { console.warn(`No valid data source found for network ${network}`); } + + if (network === SupportedNetworks.Mainnet) { + console.log('networkMarkets', networkMarkets); + } + combinedMarkets.push(...networkMarkets); } catch (networkError) { console.error(`Failed to fetch markets for network ${network}:`, networkError); diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 746a31be..567200b0 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -3,6 +3,7 @@ import { marketQuery as subgraphMarketQuery, marketsQuery as subgraphMarketsQuery, } from '@/graphql/morpho-subgraph-queries'; // Assuming query is here +import { formatBalance } from '@/utils/balance'; import { SupportedNetworks } from '@/utils/networks'; import { SubgraphMarket, @@ -11,6 +12,7 @@ import { SubgraphToken, } from '@/utils/subgraph-types'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { blacklistTokens } from '@/utils/tokens'; import { WarningWithDetail, MorphoChainlinkOracleData, Market } from '@/utils/types'; import { subgraphGraphqlFetcher } from './fetchers'; @@ -41,7 +43,7 @@ const transformSubgraphMarketToMarket = ( const lltv = subgraphMarket.lltv ?? '0'; const irmAddress = subgraphMarket.irm ?? '0x'; const inputTokenPriceUSD = subgraphMarket.inputTokenPriceUSD ?? '0'; - const totalDepositBalanceUSD = subgraphMarket.totalDepositBalanceUSD ?? '0'; + const totalBorrowBalanceUSD = subgraphMarket.totalBorrowBalanceUSD ?? '0'; const totalSupplyShares = subgraphMarket.totalSupplyShares ?? '0'; const totalBorrowShares = subgraphMarket.totalBorrowShares ?? '0'; @@ -67,10 +69,12 @@ const transformSubgraphMarketToMarket = ( const chainId = network; - const borrowAssets = subgraphMarket.totalBorrow ?? '0'; - const supplyAssets = subgraphMarket.totalSupply ?? '0'; - const collateralAssets = subgraphMarket.inputTokenBalance ?? '0'; - const collateralAssetsUsd = safeParseFloat(subgraphMarket.totalValueLockedUSD); + // @todo: might update due to input token being used here + const supplyAssets = subgraphMarket.totalSupply ?? subgraphMarket.inputTokenBalance ?? '0'; + const borrowAssets = + subgraphMarket.totalBorrow ?? subgraphMarket.variableBorrowedTokenBalance ?? '0'; + const collateralAssets = subgraphMarket.totalCollateral ?? '0'; + const timestamp = safeParseInt(subgraphMarket.lastUpdate); const totalSupplyNum = safeParseFloat(supplyAssets); @@ -80,9 +84,20 @@ const transformSubgraphMarketToMarket = ( const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0); const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0); + // only borrowBalanceUSD is available in subgraph, we need to calculate supplyAssetsUsd, liquidityAssetsUsd, collateralAssetsUsd + const borrowAssetsUsd = safeParseFloat(totalBorrowBalanceUSD); + + // get the prices + const loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); + const collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0'); + + const supplyAssetsUsd = formatBalance(supplyAssets, loanAsset.decimals) * loanAssetPrice; + const liquidityAssets = (BigInt(supplyAssets) - BigInt(borrowAssets)).toString(); - const liquidityAssetsUsd = - safeParseFloat(totalDepositBalanceUSD) - safeParseFloat(totalBorrowBalanceUSD); + const liquidityAssetsUsd = formatBalance(liquidityAssets, loanAsset.decimals) * loanAssetPrice; + + const collateralAssetsUsd = + formatBalance(collateralAssets, collateralAsset.decimals) * collateralAssetPrice; const warningsWithDetail: WarningWithDetail[] = []; // Subgraph doesn't provide warnings directly @@ -95,16 +110,20 @@ const transformSubgraphMarketToMarket = ( loanAsset: loanAsset, collateralAsset: collateralAsset, state: { + // assets borrowAssets: borrowAssets, supplyAssets: supplyAssets, - borrowAssetsUsd: totalBorrowBalanceUSD, - supplyAssetsUsd: totalDepositBalanceUSD, + liquidityAssets: liquidityAssets, + collateralAssets: collateralAssets, + // shares borrowShares: totalBorrowShares, supplyShares: totalSupplyShares, - liquidityAssets: liquidityAssets, + // usd + borrowAssetsUsd: borrowAssetsUsd, + supplyAssetsUsd: supplyAssetsUsd, liquidityAssetsUsd: liquidityAssetsUsd, - collateralAssets: collateralAssets, collateralAssetsUsd: collateralAssetsUsd, + utilization: utilization, supplyApy: supplyApy, borrowApy: borrowApy, @@ -176,13 +195,13 @@ export const fetchSubgraphMarkets = async ( throw new Error(`Subgraph URL for network ${network} is not defined.`); } - // Construct variables for the query + // Construct variables for the query, adding blacklistTokens const variables: { first: number; where?: Record; network?: string } = { first: 1000, // Max limit // If filtering is needed and supported by the schema, add it here - // where: filter, - // Pass network if the query uses it for filtering - // network: network === SupportedNetworks.Base ? 'BASE' : 'MAINNET', // Example mapping + where: { + inputToken_not_in: blacklistTokens, + }, }; // Use the new marketsQuery diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index fd62e944..3a96768f 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -36,6 +36,7 @@ export const marketFragment = ` totalBorrowShares totalSupply totalBorrow + totalCollateral fee name @@ -79,10 +80,6 @@ export const marketsQuery = ` where: $where, orderBy: totalValueLockedUSD, orderDirection: desc, - # Subgraph network filtering is typically done via the endpoint URL or a field in the 'where' clause - # Assuming the schema allows filtering by protocol network name: - # where: { protocol_: { network: $network }, ...$where } # Adjust if schema differs - # If filtering by network isn't directly supported in 'where', it might need to be handled post-fetch or by endpoint selection ) { ...SubgraphMarketFields } diff --git a/src/utils/subgraph-types.ts b/src/utils/subgraph-types.ts index e2313cb3..c6cb129b 100644 --- a/src/utils/subgraph-types.ts +++ b/src/utils/subgraph-types.ts @@ -40,29 +40,35 @@ export type SubgraphMarket = { isActive: boolean; canBorrowFrom: boolean; canUseAsCollateral: boolean; - maximumLTV: string; // BigDecimal - liquidationThreshold: string; // BigDecimal - liquidationPenalty: string; // BigDecimal - createdTimestamp: string; // BigInt - createdBlockNumber: string; // BigInt - lltv: string; // BigInt - irm: Address; // irmAddress - inputToken: SubgraphToken; // collateralAsset - inputTokenBalance: string; // BigInt (native collateral amount) + maximumLTV: string; + liquidationThreshold: string; + liquidationPenalty: string; + createdTimestamp: string; + createdBlockNumber: string; + lltv: string; + irm: Address; + inputToken: SubgraphToken; inputTokenPriceUSD: string; // BigDecimal (collateralPrice) borrowedToken: SubgraphToken; // loanAsset - variableBorrowedTokenBalance: string | null; // BigInt (native borrow amount) - totalValueLockedUSD: string; // BigDecimal (collateralAssetsUsd?) - totalDepositBalanceUSD: string; // BigDecimal (supplyAssetsUsd) - totalBorrowBalanceUSD: string; // BigDecimal (borrowAssetsUsd) - totalSupplyShares: string; // BigInt (supplyShares) + + // note: these 2 are weird + variableBorrowedTokenBalance: string | null; // updated as total Borrowed + inputTokenBalance: string; // updated as total Supply + + totalValueLockedUSD: string; + totalDepositBalanceUSD: string; + totalBorrowBalanceUSD: string; + totalSupplyShares: string; totalBorrowShares: string; // BigInt (borrowShares) - totalSupply: string; // BigInt (supplyAssets) - totalBorrow: string; // BigInt (borrowAssets) - lastUpdate: string; // BigInt (timestamp) - reserves: string; // BigDecimal - reserveFactor: string; // BigDecimal - fee: string; // BigInt (basis points?) + + totalSupply: string; + totalBorrow: string; + totalCollateral: string; + + lastUpdate: string; + reserves: string; + reserveFactor: string; + fee: string; oracle: SubgraphOracle; rates: SubgraphInterestRate[]; protocol: SubgraphProtocolInfo; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index b5a9ff83..0aea2f74 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -530,6 +530,9 @@ const isWETH = (address: string, chainId: number) => { return false; }; +// Scam tokens +const blacklistTokens = ['0xda1c2c3c8fad503662e41e324fc644dc2c5e0ccd']; + export { supportedTokens, isWETH, @@ -541,4 +544,5 @@ export { MORPHO_TOKEN_MAINNET, MORPHO_LEGACY, MORPHO_TOKEN_WRAPPER, + blacklistTokens, }; diff --git a/src/utils/types.ts b/src/utils/types.ts index 6b272562..e81ece4f 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -277,8 +277,8 @@ export type Market = { state: { borrowAssets: string; supplyAssets: string; - borrowAssetsUsd: string; - supplyAssetsUsd: string; + borrowAssetsUsd: number; + supplyAssetsUsd: number; borrowShares: string; supplyShares: string; liquidityAssets: string; From 96f8e7dc6d23ccec10236d3b717be46d9c9f8b1f Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 26 Apr 2025 18:28:55 +0800 Subject: [PATCH 06/20] feat: useLiquidations hooks --- src/data-sources/morpho-api/liquidations.ts | 117 +++++++++++++++ src/data-sources/subgraph/liquidations.ts | 72 +++++++++ src/graphql/morpho-subgraph-queries.ts | 23 +++ src/hooks/useLiquidations.ts | 154 +++++++------------- 4 files changed, 267 insertions(+), 99 deletions(-) create mode 100644 src/data-sources/morpho-api/liquidations.ts create mode 100644 src/data-sources/subgraph/liquidations.ts diff --git a/src/data-sources/morpho-api/liquidations.ts b/src/data-sources/morpho-api/liquidations.ts new file mode 100644 index 00000000..7ea42db9 --- /dev/null +++ b/src/data-sources/morpho-api/liquidations.ts @@ -0,0 +1,117 @@ +import { URLS } from '@/utils/urls'; +import { SupportedNetworks } from '@/utils/networks'; + +// Re-use the query structure from the original hook +const liquidationsQuery = ` + query getLiquidations($first: Int, $skip: Int, $chainId: Int) { + transactions( + where: { type_in: [MarketLiquidation], chainId_in: [$chainId] } # Filter by chainId + first: $first + skip: $skip + ) { + items { + data { + ... on MarketLiquidationTransactionData { + market { + uniqueKey + } + } + } + } + pageInfo { + countTotal + count + limit + skip + } + } + } +`; + +type LiquidationTransactionItem = { + data: { + market?: { + uniqueKey: string; + }; + }; +}; + +type PageInfo = { + countTotal: number; + count: number; + limit: number; + skip: number; +}; + +type QueryResult = { + data: { + transactions: { + items: LiquidationTransactionItem[]; + pageInfo: PageInfo; + }; + }; + errors?: any[]; // Add optional errors field +}; + +export const fetchMorphoApiLiquidatedMarketKeys = async ( + network: SupportedNetworks, +): Promise> => { + const liquidatedKeys = new Set(); + let skip = 0; + const pageSize = 1000; + let totalCount = 0; + + try { + do { + const response = await fetch(URLS.MORPHO_BLUE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: liquidationsQuery, + variables: { first: pageSize, skip, chainId: network }, // Pass chainId + }), + }); + + if (!response.ok) { + throw new Error(`Morpho API request failed with status ${response.status}`); + } + + const result = (await response.json()) as QueryResult; + + // Check for GraphQL errors + if (result.errors) { + console.error('GraphQL errors:', result.errors); + throw new Error(`GraphQL error fetching liquidations for network ${network}`); + } + + if (!result.data?.transactions) { + console.warn(`No transactions data found for network ${network} at skip ${skip}`); + break; // Exit loop if data structure is unexpected + } + + const liquidations = result.data.transactions.items; + const pageInfo = result.data.transactions.pageInfo; + + liquidations.forEach((tx) => { + if (tx.data?.market?.uniqueKey) { + liquidatedKeys.add(tx.data.market.uniqueKey); + } + }); + + totalCount = pageInfo.countTotal; + skip += pageInfo.count; + + // Safety break if pageInfo.count is 0 to prevent infinite loop + if (pageInfo.count === 0 && skip < totalCount) { + console.warn('Received 0 items in a page, but not yet at total count. Breaking loop.'); + break; + } + } while (skip < totalCount); + } catch (error) { + console.error(`Error fetching liquidations via Morpho API for network ${network}:`, error); + throw error; // Re-throw the error to be handled by the calling hook + } + + console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Morpho API for ${network}.`); + return liquidatedKeys; +}; \ No newline at end of file diff --git a/src/data-sources/subgraph/liquidations.ts b/src/data-sources/subgraph/liquidations.ts new file mode 100644 index 00000000..5e7977d5 --- /dev/null +++ b/src/data-sources/subgraph/liquidations.ts @@ -0,0 +1,72 @@ +import { subgraphMarketsWithLiquidationCheckQuery } from '@/graphql/morpho-subgraph-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { blacklistTokens } from '@/utils/tokens'; +import { subgraphGraphqlFetcher } from './fetchers'; + +// Define the expected structure of the response for the liquidation check query +type SubgraphMarketLiquidationCheck = { + id: string; // Market unique key + liquidates: { id: string }[]; // Array will be non-empty if liquidations exist +}; + +type SubgraphMarketsLiquidationCheckResponse = { + data: { + markets: SubgraphMarketLiquidationCheck[]; + }; + errors?: any[]; +}; + +export const fetchSubgraphLiquidatedMarketKeys = async ( + network: SupportedNetworks, +): Promise> => { + const subgraphApiUrl = getSubgraphUrl(network); + if (!subgraphApiUrl) { + console.error(`Subgraph URL for network ${network} is not defined.`); + throw new Error(`Subgraph URL for network ${network} is not defined.`); + } + + const liquidatedKeys = new Set(); + + // Apply the same base filters as fetchSubgraphMarkets + const variables = { + first: 1000, // Fetch in batches if necessary, though unlikely needed just for IDs + where: { + inputToken_not_in: blacklistTokens, + }, + }; + + try { + // Subgraph might paginate; handle if necessary, but 1000 limit is often sufficient for just IDs + const response = await subgraphGraphqlFetcher( + subgraphApiUrl, + subgraphMarketsWithLiquidationCheckQuery, + variables, + ); + + if (response.errors) { + console.error('GraphQL errors:', response.errors); + throw new Error(`GraphQL error fetching liquidated market keys for network ${network}`); + } + + const markets = response.data?.markets; + + if (!markets) { + console.warn(`No market data returned for liquidation check on network ${network}.`); + return liquidatedKeys; // Return empty set + } + + markets.forEach((market) => { + // If the liquidates array has items, this market has had liquidations + if (market.liquidates && market.liquidates.length > 0) { + liquidatedKeys.add(market.id); + } + }); + } catch (error) { + console.error(`Error fetching liquidated market keys via Subgraph for network ${network}:`, error); + throw error; // Re-throw + } + + console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Subgraph for ${network}.`); + return liquidatedKeys; +}; \ No newline at end of file diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 3a96768f..7bfb7456 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -248,3 +248,26 @@ export const marketLiquidationsAndBadDebtQuery = ` } `; // --- End Query --- + +// --- Query to check which markets have had at least one liquidation --- +export const subgraphMarketsWithLiquidationCheckQuery = ` + query getSubgraphMarketsWithLiquidationCheck( + $first: Int, + $where: Market_filter, + ) { + markets( + first: $first, + where: $where, + orderBy: totalValueLockedUSD, # Keep ordering consistent if needed, though less relevant here + orderDirection: desc, + ) { + id # Market ID (uniqueKey) + liquidates(first: 1) { # Fetch only one liquidation event to check existence + id # Need any field to confirm presence + } + # Include fields needed for filtering if the 'where' clause doesn't cover everything + # Example: inputToken { id } if filtering by inputToken needs to happen client-side (though 'where' is better) + } + } +`; +// --- End Query --- diff --git a/src/hooks/useLiquidations.ts b/src/hooks/useLiquidations.ts index bd758048..50a04c4c 100644 --- a/src/hooks/useLiquidations.ts +++ b/src/hooks/useLiquidations.ts @@ -1,71 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; -import { URLS } from '@/utils/urls'; - -const liquidationsQuery = ` - query getLiquidations($first: Int, $skip: Int) { - transactions( - where: { type_in: [MarketLiquidation] } - first: $first - skip: $skip - ) { - items { - id - type - data { - ... on MarketLiquidationTransactionData { - market { - id - uniqueKey - } - repaidAssets - } - } - hash - chain { - id - } - } - pageInfo { - countTotal - count - limit - skip - } - } - } -`; - -export type LiquidationTransaction = { - id: string; - type: string; - data: { - market: { - id: string; - uniqueKey: string; - }; - repaidAssets: string; - }; - hash: string; - chain: { - id: number; - }; -}; - -type PageInfo = { - countTotal: number; - count: number; - limit: number; - skip: number; -}; - -type QueryResult = { - data: { - transactions: { - items: LiquidationTransaction[]; - pageInfo: PageInfo; - }; - }; -}; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoApiLiquidatedMarketKeys } from '@/data-sources/morpho-api/liquidations'; +import { fetchSubgraphLiquidatedMarketKeys } from '@/data-sources/subgraph/liquidations'; +import { SupportedNetworks } from '@/utils/networks'; const useLiquidations = () => { const [loading, setLoading] = useState(true); @@ -74,48 +11,67 @@ const useLiquidations = () => { const [error, setError] = useState(null); const fetchLiquidations = useCallback(async (isRefetch = false) => { + if (isRefetch) { + setIsRefetching(true); + } else { + setLoading(true); + } + setError(null); // Reset error + + // Define the networks to check for liquidations + const networksToCheck: SupportedNetworks[] = [ + SupportedNetworks.Mainnet, + SupportedNetworks.Base, + ]; + + const combinedLiquidatedKeys = new Set(); + let fetchErrors: unknown[] = []; + try { - if (isRefetch) { - setIsRefetching(true); - } else { - setLoading(true); - } - const liquidatedKeys = new Set(); - let skip = 0; - const pageSize = 1000; - let totalCount = 0; + await Promise.all( + networksToCheck.map(async (network) => { + try { + const dataSource = getMarketDataSource(network); + let networkLiquidatedKeys: Set; - do { - const response = await fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: liquidationsQuery, - variables: { first: pageSize, skip }, - }), - }); - const result = (await response.json()) as QueryResult; - const liquidations = result.data.transactions.items; - const pageInfo = result.data.transactions.pageInfo; + console.log(`Fetching liquidated markets for ${network} via ${dataSource}`); - liquidations.forEach((tx) => { - if (tx.data && 'market' in tx.data) { - liquidatedKeys.add(tx.data.market.uniqueKey); + if (dataSource === 'morpho') { + networkLiquidatedKeys = await fetchMorphoApiLiquidatedMarketKeys(network); + } else if (dataSource === 'subgraph') { + networkLiquidatedKeys = await fetchSubgraphLiquidatedMarketKeys(network); + } else { + console.warn(`No valid data source found for network ${network} for liquidations.`); + networkLiquidatedKeys = new Set(); // Assume none if no source + } + + // Add keys from this network to the combined set + networkLiquidatedKeys.forEach((key) => combinedLiquidatedKeys.add(key)); + } catch (networkError) { + console.error( + `Failed to fetch liquidated market keys for network ${network}:`, + networkError, + ); + fetchErrors.push(networkError); // Collect errors } - }); + }), + ); - totalCount = pageInfo.countTotal; - skip += pageInfo.count; - } while (skip < totalCount); + setLiquidatedMarketKeys(combinedLiquidatedKeys); - setLiquidatedMarketKeys(liquidatedKeys); - } catch (_error) { - setError(_error); + // Set overall error if any network fetch failed + if (fetchErrors.length > 0) { + setError(fetchErrors[0]); // Or aggregate errors if needed + } + } catch (err) { + // Catch potential errors from Promise.all itself + console.error('Overall error fetching liquidations:', err); + setError(err); } finally { setLoading(false); setIsRefetching(false); } - }, []); + }, []); // Dependencies: None needed directly, fetchers are self-contained useEffect(() => { fetchLiquidations().catch(console.error); From f2a6605dfce99b18a934efe147a4b31fc1fe7908 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 26 Apr 2025 18:29:54 +0800 Subject: [PATCH 07/20] chore: lint --- src/data-sources/morpho-api/liquidations.ts | 8 +++++--- src/data-sources/subgraph/liquidations.ts | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/data-sources/morpho-api/liquidations.ts b/src/data-sources/morpho-api/liquidations.ts index 7ea42db9..64e99ca1 100644 --- a/src/data-sources/morpho-api/liquidations.ts +++ b/src/data-sources/morpho-api/liquidations.ts @@ -1,5 +1,5 @@ -import { URLS } from '@/utils/urls'; import { SupportedNetworks } from '@/utils/networks'; +import { URLS } from '@/utils/urls'; // Re-use the query structure from the original hook const liquidationsQuery = ` @@ -112,6 +112,8 @@ export const fetchMorphoApiLiquidatedMarketKeys = async ( throw error; // Re-throw the error to be handled by the calling hook } - console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Morpho API for ${network}.`); + console.log( + `Fetched ${liquidatedKeys.size} liquidated market keys via Morpho API for ${network}.`, + ); return liquidatedKeys; -}; \ No newline at end of file +}; diff --git a/src/data-sources/subgraph/liquidations.ts b/src/data-sources/subgraph/liquidations.ts index 5e7977d5..3b256473 100644 --- a/src/data-sources/subgraph/liquidations.ts +++ b/src/data-sources/subgraph/liquidations.ts @@ -63,10 +63,13 @@ export const fetchSubgraphLiquidatedMarketKeys = async ( } }); } catch (error) { - console.error(`Error fetching liquidated market keys via Subgraph for network ${network}:`, error); + console.error( + `Error fetching liquidated market keys via Subgraph for network ${network}:`, + error, + ); throw error; // Re-throw } console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Subgraph for ${network}.`); return liquidatedKeys; -}; \ No newline at end of file +}; From bd55c9e2d6943e64f622f138a75d9dd04f768bf9 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 26 Apr 2025 19:07:16 +0800 Subject: [PATCH 08/20] feat: price estimation for markets with no USD value --- src/contexts/MarketsContext.tsx | 4 +- src/data-sources/morpho-api/market.ts | 3 + src/data-sources/subgraph/market.ts | 118 +++++++++++++++++++++++--- src/utils/tokens.ts | 50 +++++++++++ src/utils/types.ts | 3 + 5 files changed, 161 insertions(+), 17 deletions(-) diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index bb4c17b6..e34e1032 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -132,15 +132,13 @@ export function MarketsProvider({ children }: MarketsProviderProps) { } } }, - [liquidatedMarketKeys], // Dependencies: liquidatedMarketKeys is needed for processing + [liquidatedMarketKeys], ); useEffect(() => { if (!liquidationsLoading && markets.length === 0) { - // Fetch markets only if liquidations are loaded and markets aren't already populated fetchMarkets().catch(console.error); } - // Dependency on fetchMarkets is correct here, also depends on liquidationsLoading }, [liquidationsLoading, fetchMarkets, markets.length]); const refetch = useCallback( diff --git a/src/data-sources/morpho-api/market.ts b/src/data-sources/morpho-api/market.ts index 6e85c9ad..bb4473fb 100644 --- a/src/data-sources/morpho-api/market.ts +++ b/src/data-sources/morpho-api/market.ts @@ -27,6 +27,9 @@ const processMarketData = (market: Market): Market => { ...market, warningsWithDetail, isProtectedByLiquidationBots: false, + + // Standard API always have USD price! + hasUSDPrice: true, }; }; diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 567200b0..d4333ef7 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -12,10 +12,58 @@ import { SubgraphToken, } from '@/utils/subgraph-types'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; -import { blacklistTokens } from '@/utils/tokens'; +import { + blacklistTokens, + ERC20Token, + findToken, + UnknownERC20Token, + TokenPeg, +} from '@/utils/tokens'; import { WarningWithDetail, MorphoChainlinkOracleData, Market } from '@/utils/types'; import { subgraphGraphqlFetcher } from './fetchers'; +// Define the structure for the fetched prices locally +type LocalMajorPrices = { + [TokenPeg.BTC]?: number; + [TokenPeg.ETH]?: number; +}; + +// Define expected type for CoinGecko API response +type CoinGeckoPriceResponse = { + bitcoin?: { usd?: number }; + ethereum?: { usd?: number }; +} + +// CoinGecko API endpoint +const COINGECKO_API_URL = + 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd'; + +// Fetcher for major prices needed for estimation +const fetchLocalMajorPrices = async (): Promise => { + try { + const response = await fetch(COINGECKO_API_URL); + if (!response.ok) { + throw new Error(`Internal CoinGecko API request failed with status ${response.status}`); + } + // Type the JSON response + const data = (await response.json()) as CoinGeckoPriceResponse; + const prices: LocalMajorPrices = { + [TokenPeg.BTC]: data.bitcoin?.usd, + [TokenPeg.ETH]: data.ethereum?.usd, + }; + // Filter out undefined prices + return Object.entries(prices).reduce((acc, [key, value]) => { + if (value !== undefined) { + acc[key as keyof LocalMajorPrices] = value; + } + return acc; + }, {} as LocalMajorPrices); + } catch (err) { + console.error('Failed to fetch internal major token prices for subgraph estimation:', err); + return {}; // Return empty object on error + } +}; + // Helper to safely parse BigDecimal/BigInt strings const safeParseFloat = (value: string | null | undefined): number => { if (value === null || value === undefined) return 0; @@ -38,6 +86,7 @@ const safeParseInt = (value: string | null | undefined): number => { const transformSubgraphMarketToMarket = ( subgraphMarket: Partial, network: SupportedNetworks, + majorPrices: LocalMajorPrices, ): Market => { const marketId = subgraphMarket.id ?? ''; const lltv = subgraphMarket.lltv ?? '0'; @@ -49,6 +98,20 @@ const transformSubgraphMarketToMarket = ( const totalBorrowShares = subgraphMarket.totalBorrowShares ?? '0'; const fee = subgraphMarket.fee ?? '0'; + // Define the estimation helper *inside* the transform function + // so it has access to majorPrices + const getEstimateValue = (token: ERC20Token | UnknownERC20Token): number | undefined => { + if (!('peg' in token) || token.peg === undefined) { + return undefined; + } + const peg = token.peg as TokenPeg; + if (peg === TokenPeg.USD) { + return 1; + } + // Access majorPrices from the outer function's scope + return majorPrices[peg]; + }; + const mapToken = (token: Partial | undefined) => ({ id: token?.id ?? '0x', address: token?.id ?? '0x', @@ -88,8 +151,23 @@ const transformSubgraphMarketToMarket = ( const borrowAssetsUsd = safeParseFloat(totalBorrowBalanceUSD); // get the prices - const loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); - const collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0'); + let loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); + let collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0'); + + // @todo: might update due to input token being used here + const hasUSDPrice = loanAssetPrice > 0 && collateralAssetPrice > 0; + if (!hasUSDPrice) { + // no price available, try to estimate + + const knownLoadAsset = findToken(loanAsset.address, network); + if (knownLoadAsset) { + loanAssetPrice = getEstimateValue(knownLoadAsset) ?? 0; + } + const knownCollateralAsset = findToken(collateralAsset.address, network); + if (knownCollateralAsset) { + collateralAssetPrice = getEstimateValue(knownCollateralAsset) ?? 0; + } + } const supplyAssetsUsd = formatBalance(supplyAssets, loanAsset.decimals) * loanAssetPrice; @@ -144,6 +222,7 @@ const transformSubgraphMarketToMarket = ( oracle: { data: defaultOracleData, // Placeholder oracle data }, + hasUSDPrice: hasUSDPrice, isProtectedByLiquidationBots: false, // Not available from subgraph badDebt: undefined, // Not available from subgraph realizedBadDebt: undefined, // Not available from subgraph @@ -179,15 +258,24 @@ export const fetchSubgraphMarket = async ( return null; // Return null if not found, hook can handle this } - return transformSubgraphMarketToMarket(marketData, network); + // Fetch major prices needed for potential estimation + const majorPrices = await fetchLocalMajorPrices(); + + return transformSubgraphMarketToMarket(marketData, network, majorPrices); }; +// Define type for GraphQL variables +type SubgraphMarketsVariables = { + first: number; + where?: { + inputToken_not_in?: string[]; + // Add other potential filter fields here if needed + }; + network?: string; // Keep network optional if sometimes omitted +} + // Fetcher for multiple markets from Subgraph -export const fetchSubgraphMarkets = async ( - network: SupportedNetworks, - // Optional filter, adjust based on actual subgraph schema capabilities - // filter?: { [key: string]: any }, -): Promise => { +export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise => { const subgraphApiUrl = getSubgraphUrl(network); if (!subgraphApiUrl) { @@ -196,9 +284,8 @@ export const fetchSubgraphMarkets = async ( } // Construct variables for the query, adding blacklistTokens - const variables: { first: number; where?: Record; network?: string } = { + const variables: SubgraphMarketsVariables = { first: 1000, // Max limit - // If filtering is needed and supported by the schema, add it here where: { inputToken_not_in: blacklistTokens, }, @@ -208,7 +295,7 @@ export const fetchSubgraphMarkets = async ( const response = await subgraphGraphqlFetcher( // Use the new response type subgraphApiUrl, subgraphMarketsQuery, // Use the new query - variables, + variables as unknown as Record, // Convert via unknown ); // Assuming the response structure matches the single market query for the list @@ -219,6 +306,9 @@ export const fetchSubgraphMarkets = async ( return []; // Return empty array if no markets or error } - // Transform each market - return marketsData.map((market) => transformSubgraphMarketToMarket(market, network)); + // Fetch major prices *once* before transforming all markets + const majorPrices = await fetchLocalMajorPrices(); + + // Transform each market using the fetched prices + return marketsData.map((market) => transformSubgraphMarketToMarket(market, network, majorPrices)); }; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 0aea2f74..8d89cdf7 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -7,6 +7,13 @@ export type SingleChainERC20Basic = { address: string; }; +// a token can be "linked" to a pegged asset, we use this to estimate the USD value for markets if it's not presented. +export enum TokenPeg { + USD = 'USD', + ETH = 'ETH', + BTC = 'BTC', +} + export type ERC20Token = { symbol: string; img: string | undefined; @@ -16,6 +23,9 @@ export type ERC20Token = { name: string; }; isFactoryToken?: boolean; + + // this is not a "hard peg", instead only used for market supply / borrow USD value estimation + peg?: TokenPeg; }; export type UnknownERC20Token = { @@ -42,12 +52,14 @@ const supportedTokens = [ { chain: mainnet, address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' }, { chain: base, address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }, ], + peg: TokenPeg.USD, }, { symbol: 'USDT', img: require('../imgs/tokens/usdt.webp') as string, decimals: 6, networks: [{ chain: mainnet, address: '0xdac17f958d2ee523a2206206994597c13d831ec7' }], + peg: TokenPeg.USD, }, { symbol: 'eUSD', @@ -61,18 +73,21 @@ const supportedTokens = [ name: 'Reserve', isProxy: true, }, + peg: TokenPeg.USD, }, { symbol: 'USDA', img: require('../imgs/tokens/usda.png') as string, decimals: 6, networks: [{ chain: mainnet, address: '0x0000206329b97DB379d5E1Bf586BbDB969C63274' }], + peg: TokenPeg.USD, }, { symbol: 'USD0', img: require('../imgs/tokens/usd0.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x73A15FeD60Bf67631dC6cd7Bc5B6e8da8190aCF5' }], + peg: TokenPeg.USD, }, { symbol: 'USD0++', @@ -83,6 +98,7 @@ const supportedTokens = [ name: 'Usual', isProxy: true, }, + peg: TokenPeg.USD, }, { symbol: 'hyUSD', @@ -93,18 +109,21 @@ const supportedTokens = [ name: 'Resolve', isProxy: true, }, + peg: TokenPeg.USD, }, { symbol: 'crvUSD', img: require('../imgs/tokens/crvusd.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0xf939E0A03FB07F59A73314E73794Be0E57ac1b4E' }], + peg: TokenPeg.USD, }, { symbol: 'USDe', img: require('../imgs/tokens/usde.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x4c9EDD5852cd905f086C759E8383e09bff1E68B3' }], + peg: TokenPeg.USD, }, { symbol: 'sUSDe', @@ -117,12 +136,14 @@ const supportedTokens = [ img: require('../imgs/tokens/frax.webp') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x853d955acef822db058eb8505911ed77f175b99e' }], + peg: TokenPeg.USD, }, { symbol: 'PYUSD', img: require('../imgs/tokens/pyusd.png') as string, decimals: 6, networks: [{ chain: mainnet, address: '0x6c3ea9036406852006290770bedfcaba0e23a0e8' }], + peg: TokenPeg.USD, }, { symbol: 'aUSD', @@ -133,12 +154,14 @@ const supportedTokens = [ name: 'Agora', isProxy: true, }, + peg: TokenPeg.USD, }, { symbol: 'sUSDS', img: require('../imgs/tokens/susds.svg') as string, decimals: 18, networks: [{ chain: base, address: '0x5875eee11cf8398102fdad704c9e96607675467a' }], + peg: TokenPeg.USD, }, { symbol: 'wUSDM', @@ -148,6 +171,7 @@ const supportedTokens = [ { chain: mainnet, address: '0x57F5E098CaD7A3D1Eed53991D4d66C45C9AF7812' }, { chain: base, address: '0x57F5E098CaD7A3D1Eed53991D4d66C45C9AF7812' }, ], + peg: TokenPeg.USD, }, { symbol: 'EURe', @@ -172,6 +196,7 @@ const supportedTokens = [ { chain: mainnet, address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' }, { chain: base, address: '0x4200000000000000000000000000000000000006' }, ], + peg: TokenPeg.ETH, }, { symbol: 'sDAI', @@ -187,24 +212,28 @@ const supportedTokens = [ { chain: mainnet, address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0' }, { chain: base, address: '0xc1cba3fcea344f92d9239c08c0568f6f2f0ee452' }, ], + peg: TokenPeg.ETH, }, { symbol: 'cbETH', img: require('../imgs/tokens/cbeth.png') as string, decimals: 18, networks: [{ address: '0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22', chain: base }], + peg: TokenPeg.ETH, }, { symbol: 'DAI', img: require('../imgs/tokens/dai.webp') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x6B175474E89094C44Da98b954EedeAC495271d0F' }], + peg: TokenPeg.USD, }, { symbol: 'gtWETH', img: undefined, decimals: 18, networks: [{ chain: mainnet, address: '0x2371e134e3455e0593363cBF89d3b6cf53740618' }], + peg: TokenPeg.ETH, }, { symbol: 'XPC', @@ -217,12 +246,14 @@ const supportedTokens = [ img: require('../imgs/tokens/oseth.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38' }], + peg: TokenPeg.ETH, }, { symbol: 'WBTC', img: require('../imgs/tokens/wbtc.png') as string, decimals: 8, networks: [{ chain: mainnet, address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599' }], + peg: TokenPeg.BTC, }, { symbol: 'cbBTC', @@ -232,12 +263,14 @@ const supportedTokens = [ { chain: mainnet, address: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf' }, { chain: base, address: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf' }, ], + peg: TokenPeg.BTC, }, { symbol: 'tBTC', img: require('../imgs/tokens/tbtc.webp') as string, decimals: 8, networks: [{ chain: mainnet, address: '0x18084fbA666a33d37592fA2633fD49a74DD93a88' }], + peg: TokenPeg.BTC, }, { symbol: 'lBTC', @@ -247,18 +280,21 @@ const supportedTokens = [ { chain: mainnet, address: '0x8236a87084f8B84306f72007F36F2618A5634494' }, { chain: base, address: '0xecAc9C5F704e954931349Da37F60E39f515c11c1' }, ], + peg: TokenPeg.BTC, }, { symbol: 'eBTC', img: require('../imgs/tokens/ebtc.webp') as string, decimals: 8, networks: [{ chain: mainnet, address: '0x657e8C867D8B37dCC18fA4Caead9C45EB088C642' }], + peg: TokenPeg.BTC, }, { symbol: 'rsETH', img: require('../imgs/tokens/rseth.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0xA1290d69c65A6Fe4DF752f95823fae25cB99e5A7' }], + peg: TokenPeg.ETH, }, { symbol: 'MKR', @@ -274,12 +310,14 @@ const supportedTokens = [ { chain: mainnet, address: '0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee' }, { chain: base, address: '0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A' }, ], + peg: TokenPeg.ETH, }, { symbol: 'apxETH', img: require('../imgs/tokens/apxeth.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x9Ba021B0a9b958B5E75cE9f6dff97C7eE52cb3E6' }], + peg: TokenPeg.ETH, }, { symbol: 'bsdETH', @@ -290,6 +328,7 @@ const supportedTokens = [ name: 'Reserve', isProxy: true, }, + peg: TokenPeg.ETH, }, { symbol: 'ETH+', @@ -300,6 +339,7 @@ const supportedTokens = [ name: 'Reserve', isProxy: true, }, + peg: TokenPeg.ETH, }, { symbol: 'LDO', @@ -315,6 +355,7 @@ const supportedTokens = [ { chain: mainnet, address: '0xae78736Cd615f374D3085123A210448E74Fc6393' }, { chain: base, address: '0xB6fe221Fe9EeF5aBa221c348bA20A1Bf5e73624c' }, ], + peg: TokenPeg.ETH, }, { symbol: 'ezETH', @@ -327,6 +368,7 @@ const supportedTokens = [ address: '0x2416092f143378750bb29b79eD961ab195CcEea5', }, ], + peg: TokenPeg.ETH, }, { symbol: 'stEUR', @@ -339,6 +381,7 @@ const supportedTokens = [ img: require('../imgs/tokens/crv.webp') as string, decimals: 18, networks: [{ chain: mainnet, address: '0xD533a949740bb3306d119CC777fa900bA034cd52' }], + peg: TokenPeg.USD, }, { symbol: 'DEGEN', @@ -357,6 +400,7 @@ const supportedTokens = [ img: require('../imgs/tokens/usyc.png') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x136471a34f6ef19fE571EFFC1CA711fdb8E49f2b' }], + peg: TokenPeg.USD, }, { symbol: 'USDz', @@ -366,24 +410,28 @@ const supportedTokens = [ { chain: mainnet, address: '0xA469B7Ee9ee773642b3e93E842e5D9b5BaA10067' }, { chain: base, address: '0x04D5ddf5f3a8939889F11E97f8c4BB48317F1938' }, ], + peg: TokenPeg.USD, }, { symbol: 'wUSDL', img: require('../imgs/tokens/wusdl.webp') as string, decimals: 18, networks: [{ chain: mainnet, address: '0x7751E2F4b8ae93EF6B79d86419d42FE3295A4559' }], + peg: TokenPeg.USD, }, { symbol: 'pufETH', img: require('../imgs/tokens/pufETH.webp') as string, decimals: 18, networks: [{ chain: mainnet, address: '0xD9A442856C234a39a81a089C06451EBAa4306a72' }], + peg: TokenPeg.ETH, }, { symbol: 'rswETH', img: require('../imgs/tokens/rsweth.webp') as string, decimals: 18, networks: [{ chain: mainnet, address: '0xFAe103DC9cf190eD75350761e95403b7b8aFa6c0' }], + peg: TokenPeg.ETH, }, { symbol: 'UNI', @@ -413,6 +461,7 @@ const supportedTokens = [ { chain: mainnet, address: '0x66a1E37c9b0eAddca17d3662D6c05F4DECf3e110' }, { chain: base, address: '0x35E5dB674D8e93a03d814FA0ADa70731efe8a4b9' }, ], + peg: TokenPeg.USD, }, { symbol: 'EIGEN', @@ -425,6 +474,7 @@ const supportedTokens = [ img: require('../imgs/tokens/wsuperOETHb.png') as string, decimals: 18, networks: [{ chain: base, address: '0x7FcD174E80f264448ebeE8c88a7C4476AAF58Ea6' }], + peg: TokenPeg.ETH, }, { symbol: 'uSOL', diff --git a/src/utils/types.ts b/src/utils/types.ts index e81ece4f..07ad0012 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -292,6 +292,9 @@ export type Market = { timestamp: number; rateAtUTarget: number; }; + + // whether we have USD price such has supplyUSD, borrowUSD, collateralUSD, etc. If not, use estimationP + hasUSDPrice: boolean; warnings: MarketWarning[]; badDebt?: { underlying: number; From 5c539b4b056079aad976bad0c96e265d4927b3f2 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 26 Apr 2025 20:34:39 +0800 Subject: [PATCH 09/20] feat: history --- src/contexts/MarketsContext.tsx | 4 - src/data-sources/morpho-api/transactions.ts | 75 +++++++ src/data-sources/subgraph/market.ts | 4 +- src/data-sources/subgraph/queries.ts | 0 src/data-sources/subgraph/transactions.ts | 212 ++++++++++++++++++++ src/data-sources/subgraph/types.ts | 65 ++++++ src/graphql/morpho-subgraph-queries.ts | 149 +++++++++++--- src/hooks/useMarketData.ts | 17 +- src/hooks/useUserTransactions.ts | 194 +++++++++++++----- 9 files changed, 625 insertions(+), 95 deletions(-) create mode 100644 src/data-sources/morpho-api/transactions.ts create mode 100644 src/data-sources/subgraph/queries.ts create mode 100644 src/data-sources/subgraph/transactions.ts create mode 100644 src/data-sources/subgraph/types.ts diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index e34e1032..56ab9ad2 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -80,10 +80,6 @@ export function MarketsProvider({ children }: MarketsProviderProps) { console.warn(`No valid data source found for network ${network}`); } - if (network === SupportedNetworks.Mainnet) { - console.log('networkMarkets', networkMarkets); - } - combinedMarkets.push(...networkMarkets); } catch (networkError) { console.error(`Failed to fetch markets for network ${network}:`, networkError); diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts new file mode 100644 index 00000000..a755c422 --- /dev/null +++ b/src/data-sources/morpho-api/transactions.ts @@ -0,0 +1,75 @@ +import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; +import { TransactionFilters, TransactionResponse } from '@/hooks/useUserTransactions'; +import { SupportedNetworks } from '@/utils/networks'; +import { URLS } from '@/utils/urls'; + +export const fetchMorphoTransactions = async ( + filters: TransactionFilters, +): Promise => { + // Conditionally construct the 'where' object + const whereClause: Record = { + userAddress_in: filters.userAddress, // Assuming this is always required + // Default chainIds if none are provided in filters for Morpho API call context + chainId_in: filters.chainIds ?? [SupportedNetworks.Base, SupportedNetworks.Mainnet], + }; + + if (filters.marketUniqueKeys && filters.marketUniqueKeys.length > 0) { + whereClause.marketUniqueKey_in = filters.marketUniqueKeys; + } + if (filters.timestampGte !== undefined && filters.timestampGte !== null) { + whereClause.timestamp_gte = filters.timestampGte; + } + if (filters.timestampLte !== undefined && filters.timestampLte !== null) { + whereClause.timestamp_lte = filters.timestampLte; + } + if (filters.hash) { + whereClause.hash = filters.hash; + } + if (filters.assetIds && filters.assetIds.length > 0) { + whereClause.assetId_in = filters.assetIds; + } + + try { + const response = await fetch(URLS.MORPHO_BLUE_API, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: userTransactionsQuery, + variables: { + where: whereClause, // Use the conditionally built 'where' clause + first: filters.first ?? 1000, + skip: filters.skip ?? 0, + }, + }), + }); + + const result = (await response.json()) as { + data?: { transactions?: TransactionResponse }; + errors?: { message: string }[]; + }; + + if (result.errors) { + throw new Error(result.errors.map((e) => e.message).join(', ')); + } + + const transactions = result.data?.transactions; + if (!transactions) { + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: 'No transaction data received from Morpho API', + }; + } + + return transactions; + } catch (err) { + console.error('Error fetching Morpho API transactions:', err); + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: err instanceof Error ? err.message : 'Unknown Morpho API error occurred', + }; + } +}; diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index d4333ef7..90686e9e 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -32,7 +32,7 @@ type LocalMajorPrices = { type CoinGeckoPriceResponse = { bitcoin?: { usd?: number }; ethereum?: { usd?: number }; -} +}; // CoinGecko API endpoint const COINGECKO_API_URL = @@ -272,7 +272,7 @@ type SubgraphMarketsVariables = { // Add other potential filter fields here if needed }; network?: string; // Keep network optional if sometimes omitted -} +}; // Fetcher for multiple markets from Subgraph export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise => { diff --git a/src/data-sources/subgraph/queries.ts b/src/data-sources/subgraph/queries.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts new file mode 100644 index 00000000..979e4f25 --- /dev/null +++ b/src/data-sources/subgraph/transactions.ts @@ -0,0 +1,212 @@ +import { subgraphUserTransactionsQuery } from '@/graphql/morpho-subgraph-queries'; +import { TransactionFilters, TransactionResponse } from '@/hooks/useUserTransactions'; +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { UserTransaction, UserTxTypes } from '@/utils/types'; +import { + SubgraphAccountData, + SubgraphBorrowTx, + SubgraphDepositTx, + SubgraphLiquidationTx, + SubgraphRepayTx, + SubgraphTransactionResponse, + SubgraphWithdrawTx, +} from './types'; + +const transformSubgraphTransactions = ( + subgraphData: SubgraphAccountData, + filters: TransactionFilters, +): TransactionResponse => { + const allTransactions: UserTransaction[] = []; + + subgraphData.deposits.forEach((tx: SubgraphDepositTx) => { + const type = tx.isCollateral ? UserTxTypes.MarketSupplyCollateral : UserTxTypes.MarketSupply; + allTransactions.push({ + hash: tx.hash, + timestamp: parseInt(tx.timestamp, 10), + type: type, + data: { + __typename: type, + shares: tx.shares, + assets: tx.amount, + market: { + uniqueKey: tx.market.id, + }, + }, + }); + }); + + subgraphData.withdraws.forEach((tx: SubgraphWithdrawTx) => { + const type = tx.isCollateral + ? UserTxTypes.MarketWithdrawCollateral + : UserTxTypes.MarketWithdraw; + allTransactions.push({ + hash: tx.hash, + timestamp: parseInt(tx.timestamp, 10), + type: type, + data: { + __typename: type, + shares: tx.shares, + assets: tx.amount, + market: { + uniqueKey: tx.market.id, + }, + }, + }); + }); + + subgraphData.borrows.forEach((tx: SubgraphBorrowTx) => { + allTransactions.push({ + hash: tx.hash, + timestamp: parseInt(tx.timestamp, 10), + type: UserTxTypes.MarketBorrow, + data: { + __typename: UserTxTypes.MarketBorrow, + shares: tx.shares, + assets: tx.amount, + market: { + uniqueKey: tx.market.id, + }, + }, + }); + }); + + subgraphData.repays.forEach((tx: SubgraphRepayTx) => { + allTransactions.push({ + hash: tx.hash, + timestamp: parseInt(tx.timestamp, 10), + type: UserTxTypes.MarketRepay, + data: { + __typename: UserTxTypes.MarketRepay, + shares: tx.shares, + assets: tx.amount, + market: { + uniqueKey: tx.market.id, + }, + }, + }); + }); + + subgraphData.liquidations.forEach((tx: SubgraphLiquidationTx) => { + allTransactions.push({ + hash: tx.hash, + timestamp: parseInt(tx.timestamp, 10), + type: UserTxTypes.MarketLiquidation, + data: { + __typename: UserTxTypes.MarketLiquidation, + shares: '0', + assets: tx.repaid, + market: { + uniqueKey: tx.market.id, + }, + }, + }); + }); + + allTransactions.sort((a, b) => b.timestamp - a.timestamp); + + const filteredTransactions = filters.marketUniqueKeys + ? allTransactions.filter((tx) => filters.marketUniqueKeys?.includes(tx.data.market.uniqueKey)) + : allTransactions; + + const count = filteredTransactions.length; + const countTotal = count; + + return { + items: filteredTransactions, + pageInfo: { + count: count, + countTotal: countTotal, + }, + error: null, + }; +}; + +export const fetchSubgraphTransactions = async ( + filters: TransactionFilters, + network: SupportedNetworks, +): Promise => { + if (filters.userAddress.length !== 1) { + console.warn('Subgraph fetcher currently supports only one user address.'); + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: null, + }; + } + + const subgraphUrl = getSubgraphUrl(network); + + if (!subgraphUrl) { + const errorMsg = `Subgraph URL not found for network ${network}. Check API key and configuration.`; + console.error(errorMsg); + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: errorMsg, + }; + } + + const userAddress = filters.userAddress[0].toLowerCase(); + + // Always calculate current timestamp (seconds) + const currentTimestamp = Math.floor(Date.now() / 1000); + + // Construct variables with mandatory timestamp filters + const variables: Record = { + userId: userAddress, + first: filters.first ?? 1000, + skip: filters.skip ?? 0, + timestamp_gt: 0, // Always start from time 0 + timestamp_lt: currentTimestamp, // Always end at current time + }; + + if (filters.timestampGte !== undefined && filters.timestampGte !== null) { + variables.timestamp_gte = filters.timestampGte; + } + if (filters.timestampLte !== undefined && filters.timestampLte !== null) { + variables.timestamp_lte = filters.timestampLte; + } + + const requestBody = { + query: subgraphUserTransactionsQuery, + variables: variables, + }; + + // Log the URL and body before sending + console.log('Subgraph Request URL:', subgraphUrl); + console.log('Subgraph Request Body:', JSON.stringify(requestBody)); + + try { + const response = await fetch(subgraphUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + + const result = (await response.json()) as SubgraphTransactionResponse; + + if (result.errors) { + throw new Error(result.errors.map((e) => e.message).join(', ')); + } + + if (!result.data?.account) { + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: null, + }; + } + + return transformSubgraphTransactions(result.data.account, filters); + } catch (err) { + console.error(`Error fetching Subgraph transactions from ${subgraphUrl}:`, err); + return { + items: [], + pageInfo: { count: 0, countTotal: 0 }, + error: err instanceof Error ? err.message : 'Unknown Subgraph error occurred', + }; + } +}; diff --git a/src/data-sources/subgraph/types.ts b/src/data-sources/subgraph/types.ts new file mode 100644 index 00000000..e7448ec5 --- /dev/null +++ b/src/data-sources/subgraph/types.ts @@ -0,0 +1,65 @@ +import { Address } from 'viem'; + +type SubgraphAsset = { + id: string; // Asset address + symbol?: string; // Optional symbol + decimals?: number; // Optional decimals +}; + +type SubgraphMarketReference = { + id: string; // Market unique key +}; + +type SubgraphAccountReference = { + id: Address; +}; + +type SubgraphBaseTx = { + id: string; // Transaction ID (e.g., hash + log index) + hash: string; // Transaction hash + timestamp: string; // Timestamp string (needs conversion to number) + market: SubgraphMarketReference; // Reference to the market + asset: SubgraphAsset; // Reference to the asset involved + amount: string; // Amount of the asset (loan/collateral) + shares: string; // Amount in shares + accountActor?: SubgraphAccountReference; // Optional: msg.sender for deposits etc. +}; + +export type SubgraphDepositTx = SubgraphBaseTx & { + isCollateral: boolean; // True for SupplyCollateral, False for Supply +}; + +export type SubgraphWithdrawTx = SubgraphBaseTx & { + isCollateral: boolean; // True for WithdrawCollateral, False for Withdraw +}; + +export type SubgraphBorrowTx = SubgraphBaseTx; + +export type SubgraphRepayTx = SubgraphBaseTx; + +export type SubgraphLiquidationTx = { + id: string; + hash: string; + timestamp: string; + market: SubgraphMarketReference; + liquidator: SubgraphAccountReference; // The account calling liquidate + amount: string; // Collateral seized amount (string) + repaid: string; // Debt repaid amount (string) +}; + +// Structure based on the example query { account(id: ...) { ... } } +export type SubgraphAccountData = { + deposits: SubgraphDepositTx[]; + withdraws: SubgraphWithdrawTx[]; + borrows: SubgraphBorrowTx[]; + repays: SubgraphRepayTx[]; + liquidations: SubgraphLiquidationTx[]; // Assuming liquidations where user was liquidated +}; + +// The full response structure from the subgraph query +export type SubgraphTransactionResponse = { + data: { + account: SubgraphAccountData | null; + }; + errors?: { message: string }[]; +}; diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 7bfb7456..2eb91b91 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -162,9 +162,7 @@ export const marketDepositsWithdrawsQuery = ` where: { market: $marketId, asset: $loanAssetId } ) { amount - account { - id - } + account { id } timestamp hash } @@ -175,9 +173,7 @@ export const marketDepositsWithdrawsQuery = ` where: { market: $marketId, asset: $loanAssetId } ) { amount - account { - id - } + account { id } timestamp hash } @@ -195,9 +191,7 @@ export const marketBorrowsRepaysQuery = ` where: { market: $marketId, asset: $loanAssetId } ) { amount - account { - id - } + account { id } timestamp hash } @@ -208,9 +202,7 @@ export const marketBorrowsRepaysQuery = ` where: { market: $marketId, asset: $loanAssetId } ) { amount - account { - id - } + account { id } timestamp hash } @@ -227,23 +219,19 @@ export const marketLiquidationsAndBadDebtQuery = ` orderBy: timestamp, orderDirection: desc ) { - id # ID of the liquidate event itself + id hash timestamp - repaid # Amount of loan asset repaid - amount # Amount of collateral seized - liquidator { - id - } + repaid + amount + liquidator { id } } badDebtRealizations( first: 1000, where: { market: $marketId } ) { badDebt - liquidation { - id - } + liquidation { id } } } `; @@ -258,16 +246,123 @@ export const subgraphMarketsWithLiquidationCheckQuery = ` markets( first: $first, where: $where, - orderBy: totalValueLockedUSD, # Keep ordering consistent if needed, though less relevant here + orderBy: totalValueLockedUSD, orderDirection: desc, ) { id # Market ID (uniqueKey) - liquidates(first: 1) { # Fetch only one liquidation event to check existence - id # Need any field to confirm presence + liquidates(first: 1) { # Fetch only one to check existence + id + } + } + } +`; + +// Note: The exact field names might need adjustment based on the specific Subgraph schema. +export const subgraphUserTransactionsQuery = ` + 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 + ) { + account(id: $userId) { + deposits( + first: $first + skip: $skip + orderBy: timestamp + orderDirection: desc + where: { + timestamp_gt: $timestamp_gt + timestamp_lt: $timestamp_lt + } + ) { + id + hash + timestamp + isCollateral + market { id } + asset { id } + amount + shares + accountActor { id } + } + withdraws( + first: $first + skip: $skip + orderBy: timestamp + orderDirection: desc + where: { + timestamp_gt: $timestamp_gt + timestamp_lt: $timestamp_lt + } + ) { + id + hash + timestamp + isCollateral + market { id } + asset { id } + amount + shares + accountActor { id } + } + borrows( + first: $first + skip: $skip + orderBy: timestamp + orderDirection: desc + where: { + timestamp_gt: $timestamp_gt + timestamp_lt: $timestamp_lt + } + ) { + id + hash + timestamp + market { id } + asset { id } + amount + shares + accountActor { id } + } + repays( + first: $first + skip: $skip + orderBy: timestamp + orderDirection: desc + where: { + timestamp_gt: $timestamp_gt + timestamp_lt: $timestamp_lt + } + ) { + id + hash + timestamp + market { id } + asset { id } + amount + shares + accountActor { id } + } + liquidations( + first: $first + skip: $skip + orderBy: timestamp + orderDirection: desc + where: { + timestamp_gt: $timestamp_gt + timestamp_lt: $timestamp_lt + } + ) { + id + hash + timestamp + market { id } + liquidator { id } + amount # Collateral seized + repaid # Debt repaid } - # Include fields needed for filtering if the 'where' clause doesn't cover everything - # Example: inputToken { id } if filtering by inputToken needs to happen client-side (though 'where' is better) } } `; -// --- End Query --- diff --git a/src/hooks/useMarketData.ts b/src/hooks/useMarketData.ts index 0668422a..869aca61 100644 --- a/src/hooks/useMarketData.ts +++ b/src/hooks/useMarketData.ts @@ -11,42 +11,35 @@ export const useMarketData = ( ) => { const queryKey = ['marketData', uniqueKey, network]; - // Determine the data source const dataSource = network ? getMarketDataSource(network) : null; const { data, isLoading, error, refetch } = useQuery({ - // Allow null return queryKey: queryKey, queryFn: async (): Promise => { - // Guard clauses if (!uniqueKey || !network || !dataSource) { - return null; // Return null if prerequisites aren't met + return null; } console.log(`Fetching market data for ${uniqueKey} on ${network} via ${dataSource}`); - // Fetch based on the determined data source try { if (dataSource === 'morpho') { return await fetchMorphoMarket(uniqueKey, network); } else if (dataSource === 'subgraph') { - // fetchSubgraphMarket already handles potential null return return await fetchSubgraphMarket(uniqueKey, network); } } catch (fetchError) { console.error(`Failed to fetch market data via ${dataSource}:`, fetchError); - return null; // Return null on fetch error + return null; } - // Fallback if dataSource logic is somehow incorrect console.warn('Unknown market data source determined'); return null; }, - // Enable query only if all parameters are present AND a valid data source exists enabled: !!uniqueKey && !!network && !!dataSource, - staleTime: 1000 * 60 * 5, // 5 minutes + staleTime: 1000 * 60 * 5, placeholderData: (previousData) => previousData ?? null, - retry: 1, // Optional: retry once on failure + retry: 1, }); return { @@ -54,6 +47,6 @@ export const useMarketData = ( isLoading: isLoading, error: error, refetch: refetch, - dataSource: dataSource, // Expose the determined data source + dataSource: dataSource, }; }; diff --git a/src/hooks/useUserTransactions.ts b/src/hooks/useUserTransactions.ts index 88ee588a..74689167 100644 --- a/src/hooks/useUserTransactions.ts +++ b/src/hooks/useUserTransactions.ts @@ -1,13 +1,14 @@ import { useState, useCallback } from 'react'; -import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; -import { SupportedNetworks } from '@/utils/networks'; +import { getMarketDataSource } 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 { UserTransaction } from '@/utils/types'; -import { URLS } from '@/utils/urls'; export type TransactionFilters = { - userAddress: string[]; + userAddress: string[]; // Expecting only one for subgraph compatibility marketUniqueKeys?: string[]; - chainIds?: number[]; + chainIds?: number[]; // Optional: If provided, fetch only from these chains timestampGte?: number; timestampLte?: number; skip?: number; @@ -19,67 +20,160 @@ export type TransactionFilters = { export type TransactionResponse = { items: UserTransaction[]; pageInfo: { - count: number; - countTotal: number; + 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 [error, setError] = useState(null); const fetchTransactions = useCallback( async (filters: TransactionFilters): Promise => { - try { - setLoading(true); - setError(null); - - const response = await fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: userTransactionsQuery, - variables: { - where: { - userAddress_in: filters.userAddress, - marketUniqueKey_in: filters.marketUniqueKeys ?? null, - chainId_in: filters.chainIds ?? [SupportedNetworks.Base, SupportedNetworks.Mainnet], - timestamp_gte: filters.timestampGte ?? null, - timestamp_lte: filters.timestampLte ?? null, - hash: filters.hash ?? null, - assetId_in: filters.assetIds ?? null, - }, - first: filters.first ?? 1000, - skip: filters.skip ?? 0, - }, - }), - }); - - const result = (await response.json()) as { - data?: { transactions?: TransactionResponse }; - errors?: { message: string }[]; - }; + setLoading(true); + setError(null); - if (result.errors) { - throw new Error(result.errors[0].message); - } + // 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 }; + } - const transactions = result.data?.transactions as TransactionResponse; - return transactions; - } catch (err) { - console.error('Error fetching transactions:', err); - setError(err); + // Check for subgraph user address limitation + const usesSubgraph = targetNetworks.some( + (network) => getMarketDataSource(network) === 'subgraph', + ); + 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: err instanceof Error ? err.message : 'Unknown error occurred', + error: 'Subgraph data source requires exactly one user address.', }; - } finally { - setLoading(false); } + + // 2. Categorize networks by data source (numeric enum values) + const morphoNetworks: SupportedNetworks[] = []; + const subgraphNetworks: SupportedNetworks[] = []; + + targetNetworks.forEach((network) => { + // network is now guaranteed to be a numeric enum value (e.g., 1, 8453) + if (getMarketDataSource(network) === 'subgraph') { + subgraphNetworks.push(network); + } else { + morphoNetworks.push(network); + } + }); + + // 3. Create fetch promises + const fetchPromises: Promise[] = []; + + console.log('morphoNetworks', morphoNetworks); + + // Morpho API Fetch + if (morphoNetworks.length > 0) { + // morphoNetworks directly contains the numeric chain IDs (e.g., [1, ...]) + console.log(`Queueing fetch from Morpho API for chain IDs: ${morphoNetworks.join(', ')}`); + const morphoFilters = { + ...filters, + chainIds: morphoNetworks, // Pass the numeric IDs directly + first: MAX_ITEMS_PER_SOURCE, + skip: 0, + }; + fetchPromises.push(fetchMorphoTransactions(morphoFilters)); + } + + // Subgraph Fetches + subgraphNetworks.forEach((network) => { + // network is the numeric enum value (e.g., 8453) + console.log(`Queueing fetch from Subgraph for network ID: ${network}`); + const subgraphFilters = { + ...filters, + chainIds: [network], // Pass the single numeric ID for context + first: MAX_ITEMS_PER_SOURCE, + skip: 0, + }; + // Pass the enum value (which is the number) to fetchSubgraphTransactions + fetchPromises.push(fetchSubgraphTransactions(subgraphFilters, network)); + }); + + // 4. Execute promises in parallel + const results = await Promise.allSettled(fetchPromises); + + // 5. Combine results + let combinedItems: UserTransaction[] = []; + let combinedTotalCount = 0; + const errors: string[] = []; + + results.forEach((result, index) => { + const networkDescription = + index < (morphoNetworks.length > 0 ? 1 : 0) + ? `Morpho API (${morphoNetworks.join(', ')})` + : `Subgraph (${subgraphNetworks[index - (morphoNetworks.length > 0 ? 1 : 0)]})`; // Adjust index for subgraph networks + + if (result.status === 'fulfilled') { + const response = result.value; + if (response.error) { + console.warn(`Error from ${networkDescription}: ${response.error}`); + errors.push(`Error from ${networkDescription}: ${response.error}`); + } else { + combinedItems = combinedItems.concat(response.items); + combinedTotalCount += response.pageInfo.countTotal; // Aggregate total count + console.log(`Received ${response.items.length} items from ${networkDescription}`); + } + } else { + console.error(`Failed to fetch from ${networkDescription}:`, result.reason); + errors.push( + `Failed to fetch from ${networkDescription}: ${ + result.reason?.message || 'Unknown error' + }`, + ); + } + }); + + // 6. Sort combined results by timestamp + combinedItems.sort((a, b) => b.timestamp - a.timestamp); + + // 7. Apply client-side pagination + const skip = filters.skip ?? 0; + const first = filters.first ?? combinedItems.length; // Default to all items if 'first' is not provided + 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, // Note: This is an estimated total + }, + error: finalError, + }; }, [], ); From dc7d04734b0c61f14471d3de1713c24b27177d64 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 26 Apr 2025 20:37:48 +0800 Subject: [PATCH 10/20] chore: fix useLiquidations --- src/config/dataSources.ts | 8 ++++---- src/data-sources/morpho-api/liquidations.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts index f8e55e8c..e27d8b67 100644 --- a/src/config/dataSources.ts +++ b/src/config/dataSources.ts @@ -5,10 +5,10 @@ import { SupportedNetworks } from '@/utils/networks'; */ export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { switch (network) { - case SupportedNetworks.Mainnet: - return 'subgraph'; - case SupportedNetworks.Base: - return 'subgraph'; + // case SupportedNetworks.Mainnet: + // return 'subgraph'; + // case SupportedNetworks.Base: + // return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/data-sources/morpho-api/liquidations.ts b/src/data-sources/morpho-api/liquidations.ts index 64e99ca1..71301982 100644 --- a/src/data-sources/morpho-api/liquidations.ts +++ b/src/data-sources/morpho-api/liquidations.ts @@ -3,7 +3,7 @@ import { URLS } from '@/utils/urls'; // Re-use the query structure from the original hook const liquidationsQuery = ` - query getLiquidations($first: Int, $skip: Int, $chainId: Int) { + query getLiquidations($first: Int, $skip: Int, $chainId: Int!) { transactions( where: { type_in: [MarketLiquidation], chainId_in: [$chainId] } # Filter by chainId first: $first From 12b600cca5c80b2fa422bdf9e5204de3b6e431d1 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sat, 26 Apr 2025 23:33:21 +0800 Subject: [PATCH 11/20] feat: get positions --- src/contexts/MarketsContext.tsx | 3 +- src/data-sources/morpho-api/positions.ts | 76 ++++++ src/data-sources/subgraph/positions.ts | 66 +++++ src/data-sources/subgraph/transactions.ts | 4 - src/graphql/morpho-subgraph-queries.ts | 14 + src/hooks/useUserPositions.ts | 318 +++++++++++----------- src/hooks/useUserPositionsSummaryData.ts | 3 - 7 files changed, 315 insertions(+), 169 deletions(-) create mode 100644 src/data-sources/morpho-api/positions.ts create mode 100644 src/data-sources/subgraph/positions.ts diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index 56ab9ad2..db1bbbed 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -17,7 +17,8 @@ import { isSupportedChain, SupportedNetworks } from '@/utils/networks'; import { Market } from '@/utils/types'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; -type MarketsContextType = { +// Export the type definition +export type MarketsContextType = { markets: Market[]; loading: boolean; isRefetching: boolean; diff --git a/src/data-sources/morpho-api/positions.ts b/src/data-sources/morpho-api/positions.ts new file mode 100644 index 00000000..bc032ce3 --- /dev/null +++ b/src/data-sources/morpho-api/positions.ts @@ -0,0 +1,76 @@ +import { userPositionsQuery } from '@/graphql/morpho-api-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { MarketPosition } from '@/utils/types'; +import { URLS } from '@/utils/urls'; + +// Type for the raw response from the Morpho API userPositionsQuery +type MorphoUserPositionsApiResponse = { + data?: { + userByAddress?: { + marketPositions?: MarketPosition[]; + }; + }; + errors?: { message: string }[]; +}; + +// Type for a valid position with required fields +type ValidMarketPosition = MarketPosition & { + market: { + uniqueKey: string; + morphoBlue: { chain: { id: number } }; + }; +}; + +/** + * Fetches the unique keys of markets where a user has a position from the Morpho API. + */ +export const fetchMorphoUserPositionMarkets = async ( + userAddress: string, + network: SupportedNetworks, +): Promise<{ marketUniqueKey: string; chainId: number }[]> => { + try { + const response = await fetch(URLS.MORPHO_BLUE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: userPositionsQuery, + variables: { + address: userAddress.toLowerCase(), + chainId: network, + }, + }), + }); + + const result = (await response.json()) as MorphoUserPositionsApiResponse; + + if (result.errors) { + console.error( + `Morpho API error fetching position markets for ${userAddress} on ${network}:`, + result.errors, + ); + throw new Error(result.errors.map((e) => e.message).join('; ')); + } + + const marketPositions = result.data?.userByAddress?.marketPositions ?? []; + + // Filter for valid positions and extract market key and chain ID + const positionMarkets = marketPositions + .filter( + (position): position is ValidMarketPosition => + position.market?.uniqueKey !== undefined && + position.market?.morphoBlue?.chain?.id !== undefined, + ) + .map((position) => ({ + marketUniqueKey: position.market.uniqueKey, + chainId: position.market.morphoBlue.chain.id, + })); + + return positionMarkets; + } catch (error) { + console.error( + `Failed to fetch position markets from Morpho API for ${userAddress} on ${network}:`, + error, + ); + return []; // Return empty array on error + } +}; diff --git a/src/data-sources/subgraph/positions.ts b/src/data-sources/subgraph/positions.ts new file mode 100644 index 00000000..a795534e --- /dev/null +++ b/src/data-sources/subgraph/positions.ts @@ -0,0 +1,66 @@ +import { subgraphUserPositionMarketsQuery } from '@/graphql/morpho-subgraph-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; + +type SubgraphPositionMarketResponse = { + data?: { + account?: { + positions?: { + market: { + id: string; + }; + }[]; + }; + }; + errors?: { message: string }[]; +}; + +/** + * Fetches the unique keys of markets where a user has a position from the Subgraph. + */ +export const fetchSubgraphUserPositionMarkets = async ( + userAddress: string, + network: SupportedNetworks, +): Promise<{ marketUniqueKey: string; chainId: number }[]> => { + const endpoint = getSubgraphUrl(network); + if (!endpoint) { + console.warn(`No subgraph endpoint found for network ${network}`); + return []; + } + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: subgraphUserPositionMarketsQuery, + variables: { + userId: userAddress.toLowerCase(), + }, + }), + }); + + const result = (await response.json()) as SubgraphPositionMarketResponse; + + if (result.errors) { + console.error( + `Subgraph error fetching position markets for ${userAddress} on ${network}:`, + result.errors, + ); + throw new Error(result.errors.map((e) => e.message).join('; ')); + } + + const positions = result.data?.account?.positions ?? []; + + return positions.map((pos) => ({ + marketUniqueKey: pos.market.id, + chainId: network, // The network ID is passed in + })); + } catch (error) { + console.error( + `Failed to fetch position markets from subgraph for ${userAddress} on ${network}:`, + error, + ); + return []; // Return empty array on error + } +}; diff --git a/src/data-sources/subgraph/transactions.ts b/src/data-sources/subgraph/transactions.ts index 979e4f25..7443ee97 100644 --- a/src/data-sources/subgraph/transactions.ts +++ b/src/data-sources/subgraph/transactions.ts @@ -173,10 +173,6 @@ export const fetchSubgraphTransactions = async ( variables: variables, }; - // Log the URL and body before sending - console.log('Subgraph Request URL:', subgraphUrl); - console.log('Subgraph Request Body:', JSON.stringify(requestBody)); - try { const response = await fetch(subgraphUrl, { method: 'POST', diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index 2eb91b91..b733ae8a 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -257,6 +257,20 @@ export const subgraphMarketsWithLiquidationCheckQuery = ` } `; +// --- Query for User Position Market IDs --- +export const subgraphUserPositionMarketsQuery = ` + query GetUserPositionMarkets($userId: ID!) { + account(id: $userId) { + positions(first: 1000) { # Assuming a user won't have > 1000 positions + market { + id # Market unique key + } + } + } + } +`; +// --- End Query --- + // Note: The exact field names might need adjustment based on the specific Subgraph schema. export const subgraphUserTransactionsQuery = ` query GetUserTransactions( diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 64b746b2..c3c1ceb0 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -1,222 +1,213 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ - import { useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Address } from 'viem'; -import { userPositionsQuery } from '@/graphql/morpho-api-queries'; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoUserPositionMarkets } from '@/data-sources/morpho-api/positions'; +import { fetchSubgraphUserPositionMarkets } from '@/data-sources/subgraph/positions'; import { SupportedNetworks } from '@/utils/networks'; import { fetchPositionSnapshot, type PositionSnapshot } from '@/utils/positions'; -import { MarketPosition, Market } from '@/utils/types'; -import { URLS } from '@/utils/urls'; +import { Market } from '@/utils/types'; import { getMarketWarningsWithDetail } from '@/utils/warnings'; import { useUserMarketsCache } from '../hooks/useUserMarketsCache'; import { useMarkets } from './useMarkets'; -type UserPositionsResponse = { - marketPositions: MarketPosition[]; - usedMarkets: { - marketUniqueKey: string; - chainId: number; - }[]; +// Type for market key and chain identifier +type PositionMarket = { + marketUniqueKey: string; + chainId: number; +}; + +// Type returned by the first query +type InitialDataResponse = { + finalMarketKeys: PositionMarket[]; }; +// Type for object used to fetch snapshot details type MarketToFetch = { marketKey: string; chainId: number; market: Market; - existingState: PositionSnapshot | null; }; +// Type for the final processed position data type EnhancedMarketPosition = { state: PositionSnapshot; market: Market & { warningsWithDetail: ReturnType }; }; +// Type for the result of a single snapshot fetch type SnapshotResult = { market: Market; state: PositionSnapshot | null; } | null; -type ValidMarketPosition = MarketPosition & { - market: Market & { - uniqueKey: string; - morphoBlue: { chain: { id: number } }; - }; -}; - -// Query keys for caching +// --- Query Keys (adjusted for two-step process) --- export const positionKeys = { all: ['positions'] as const, - user: (address: string) => [...positionKeys.all, address] as const, + // Key for the initial fetch of relevant market keys + initialData: (user: string) => [...positionKeys.all, 'initialData', user] as const, + // Key for fetching the on-chain snapshot state for a specific market (used internally by queryClient) snapshot: (marketKey: string, userAddress: string, chainId: number) => [...positionKeys.all, 'snapshot', marketKey, userAddress, chainId] as const, - enhanced: (user: string | undefined, data: UserPositionsResponse | undefined) => - ['enhanced-positions', user, data] as const, + // Key for the final enhanced position data, dependent on initialData result + enhanced: (user: string | undefined, initialData: InitialDataResponse | undefined) => + ['enhanced-positions', user, initialData] as const, }; -const fetchUserPositions = async ( - user: string, - getUserMarkets: () => { marketUniqueKey: string; chainId: number }[], -): Promise => { - console.log('🔄 Fetching user positions for:', user); +// --- Helper Fetch Function --- // + +// Fetches market keys ONLY from API/Subgraph sources +const fetchSourceMarketKeys = async (user: string): Promise => { + const allSupportedNetworks = Object.values(SupportedNetworks).filter( + (value) => typeof value === 'number', + ) as SupportedNetworks[]; - const [responseMainnet, responseBase] = await Promise.all([ - fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: userPositionsQuery, - variables: { - address: user.toLowerCase(), - chainId: SupportedNetworks.Mainnet, - }, - }), - }), - fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: userPositionsQuery, - variables: { - address: user.toLowerCase(), - chainId: SupportedNetworks.Base, - }, - }), - }), - ]); + const morphoNetworks: SupportedNetworks[] = []; + const subgraphNetworks: SupportedNetworks[] = []; - const [result1, result2] = await Promise.all([responseMainnet.json(), responseBase.json()]); + allSupportedNetworks.forEach((network: SupportedNetworks) => { + const source = getMarketDataSource(network); + if (source === 'subgraph') { + subgraphNetworks.push(network); + } else { + morphoNetworks.push(network); + } + }); - console.log('📊 Received positions data from both networks'); + const fetchPromises: Promise[] = []; - const usedMarkets = getUserMarkets(); - const marketPositions: MarketPosition[] = []; + morphoNetworks.forEach((network) => { + fetchPromises.push(fetchMorphoUserPositionMarkets(user, network)); + }); + subgraphNetworks.forEach((network) => { + fetchPromises.push(fetchSubgraphUserPositionMarkets(user, network)); + }); - // Collect positions - for (const result of [result1, result2]) { - if (result.data?.userByAddress?.marketPositions) { - marketPositions.push(...(result.data.userByAddress.marketPositions as MarketPosition[])); + const results = await Promise.allSettled(fetchPromises); + + let sourcePositionMarkets: PositionMarket[] = []; + results.forEach((result, index) => { + if (result.status === 'fulfilled') { + sourcePositionMarkets = sourcePositionMarkets.concat(result.value); + } else { + const network = [...morphoNetworks, ...subgraphNetworks][index]; + const source = getMarketDataSource(network); + console.error( + `[Positions] Failed to fetch from ${source} for network ${network}:`, + result.reason, + ); } - } - - return { marketPositions, usedMarkets }; + }); + // console.log(`[Positions] Fetched ${sourcePositionMarkets.length} keys from sources.`); + return sourcePositionMarkets; }; +// --- Main Hook --- // + const useUserPositions = (user: string | undefined, showEmpty = false) => { const queryClient = useQueryClient(); - const { markets } = useMarkets(); + const { markets } = useMarkets(); // Get markets list (loading state not directly used for enabling 2nd query) const { getUserMarkets, batchAddUserMarkets } = useUserMarketsCache(user); - // Main query for user positions + // 1. Query for initial data: Fetch keys from sources, combine with cache, deduplicate const { - data: positionsData, - isLoading: isLoadingPositions, - isRefetching: isRefetchingPositions, - error: positionsError, - refetch: refetchPositions, - } = useQuery({ - queryKey: positionKeys.user(user ?? ''), + data: initialData, + isLoading: isLoadingInitialData, // Primary loading state + isRefetching: isRefetchingInitialData, + error: initialError, + refetch: refetchInitialData, + } = useQuery({ + // Note: Removed MarketsContextType type assertion + queryKey: positionKeys.initialData(user ?? ''), queryFn: async () => { - if (!user) throw new Error('Missing user address'); - return fetchUserPositions(user, getUserMarkets); + // User is guaranteed non-null here due to the 'enabled' flag + if (!user) throw new Error('Assertion failed: User should be defined here.'); + + // Fetch keys from API/Subgraph + const sourceMarketKeys = await fetchSourceMarketKeys(user); + // Get keys from cache + const usedMarkets = getUserMarkets(); + // Combine and deduplicate + const combinedMarkets = [...sourceMarketKeys, ...usedMarkets]; + const uniqueMarketsMap = new Map(); + combinedMarkets.forEach((market) => { + const key = `${market.marketUniqueKey.toLowerCase()}-${market.chainId}`; + if (!uniqueMarketsMap.has(key)) { + uniqueMarketsMap.set(key, market); + } + }); + const finalMarketKeys = Array.from(uniqueMarketsMap.values()); + // console.log(`[Positions] Query 1: Final unique keys count: ${finalMarketKeys.length}`); + return { finalMarketKeys }; }, - enabled: !!user, - staleTime: 30000, // Consider data fresh for 30 seconds - gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + enabled: !!user && markets.length > 0, + staleTime: 30000, + gcTime: 5 * 60 * 1000, }); - // Query for position snapshots + // 2. Query for enhanced position data (snapshots), dependent on initialData const { data: enhancedPositions, isRefetching: isRefetchingEnhanced } = useQuery< EnhancedMarketPosition[] >({ - queryKey: positionKeys.enhanced(user, positionsData), + queryKey: positionKeys.enhanced(user, initialData), queryFn: async () => { - if (!positionsData || !user) return []; - - console.log('🔄 Fetching position snapshots'); - - const { marketPositions, usedMarkets } = positionsData; - - // We need to fetch snapshots for ALL markets - both from API and used ones - const knownMarkets = marketPositions - .filter( - (position): position is ValidMarketPosition => - position.market?.uniqueKey !== undefined && - position.market?.morphoBlue?.chain?.id !== undefined, - ) - .map( - (position): MarketToFetch => ({ - marketKey: position.market.uniqueKey, - chainId: position.market.morphoBlue.chain.id, - market: position.market, - existingState: position.state, - }), - ); - - const marketsToRescan = usedMarkets - .filter((market) => { - return !marketPositions.find( - (position) => - position.market?.uniqueKey?.toLowerCase() === market.marketUniqueKey.toLowerCase() && - position.market?.morphoBlue?.chain?.id === market.chainId, - ); - }) - .map((market) => { - const marketWithDetails = markets.find( - (m) => - m.uniqueKey?.toLowerCase() === market.marketUniqueKey.toLowerCase() && - m.morphoBlue?.chain?.id === market.chainId, + // initialData and user are guaranteed non-null here due to the 'enabled' flag + if (!initialData || !user) + throw new Error('Assertion failed: initialData/user should be defined here.'); + + const { finalMarketKeys } = initialData; + // console.log(`[Positions] Query 2: Processing ${finalMarketKeys.length} keys for snapshots.`); + + // Find market details using the main `markets` list from context + const allMarketsToFetch: MarketToFetch[] = finalMarketKeys + .map((marketInfo) => { + const marketDetails = markets.find( + (m: Market) => + m.uniqueKey?.toLowerCase() === marketInfo.marketUniqueKey.toLowerCase() && + m.morphoBlue?.chain?.id === marketInfo.chainId, ); - if ( - !marketWithDetails || - !marketWithDetails.uniqueKey || - !marketWithDetails.morphoBlue?.chain?.id - ) { + if (!marketDetails) { + console.warn( + `[Positions] Market details not found for ${marketInfo.marketUniqueKey} on chain ${marketInfo.chainId}. Skipping snapshot fetch.`, + ); return null; } return { - marketKey: market.marketUniqueKey, - chainId: market.chainId, - market: marketWithDetails, - existingState: null, - } as MarketToFetch; + marketKey: marketInfo.marketUniqueKey, + chainId: marketInfo.chainId, + market: marketDetails, + }; }) .filter((item): item is MarketToFetch => item !== null); - const allMarketsToFetch: MarketToFetch[] = [...knownMarkets, ...marketsToRescan]; - - console.log(`🔄 Fetching snapshots for ${allMarketsToFetch.length} markets`); + // console.log(`[Positions] Query 2: Fetching snapshots for ${allMarketsToFetch.length} markets.`); - // Fetch snapshots in parallel using React Query's built-in caching + // Fetch snapshots in parallel const snapshots = await Promise.all( - allMarketsToFetch.map( - async ({ marketKey, chainId, market, existingState }): Promise => { - const snapshot = await queryClient.fetchQuery({ - queryKey: positionKeys.snapshot(marketKey, user, chainId), - queryFn: async () => fetchPositionSnapshot(marketKey, user as Address, chainId, 0), - staleTime: 30000, - gcTime: 5 * 60 * 1000, - }); - - if (!snapshot && !existingState) return null; - - return { - market, - state: snapshot ?? existingState, - }; - }, - ), + allMarketsToFetch.map(async ({ marketKey, chainId, market }): Promise => { + const snapshot = await queryClient.fetchQuery({ + queryKey: positionKeys.snapshot(marketKey, user, chainId), + queryFn: async () => fetchPositionSnapshot(marketKey, user as Address, chainId, 0), + staleTime: 30000, // Use same staleTime as main queries + gcTime: 5 * 60 * 1000, + }); + // No fallback to existingState here, unlike original logic + return snapshot ? { market, state: snapshot } : null; + }), ); - console.log('📊 Received position snapshots'); - - // Filter out null results and process positions + // Process valid snapshots const validPositions = snapshots .filter( (item): item is NonNullable & { state: NonNullable } => item !== null && item.state !== null, ) - .filter((position) => showEmpty || position.state.supplyShares.toString() !== '0') + .filter((position) => { + const hasSupply = position.state.supplyShares.toString() !== '0'; + const hasBorrow = position.state.borrowShares.toString() !== '0'; + const hasCollateral = position.state.collateral.toString() !== '0'; + return showEmpty || hasSupply || hasBorrow || hasCollateral; + }) .map((position) => ({ state: position.state, market: { @@ -225,7 +216,7 @@ const useUserPositions = (user: string | undefined, showEmpty = false) => { }, })); - // Update market cache with all valid positions + // Update market cache const marketsToCache = validPositions .filter((position) => position.market?.uniqueKey && position.market?.morphoBlue?.chain?.id) .map((position) => ({ @@ -237,33 +228,38 @@ const useUserPositions = (user: string | undefined, showEmpty = false) => { batchAddUserMarkets(marketsToCache); } + // console.log(`[Positions] Query 2: Processed ${validPositions.length} valid positions.`); return validPositions; }, - enabled: !!positionsData && !!user, + // Enable this query only when the first query has successfully run + enabled: !!initialData && !!user, + // This query represents derived data, stale/gc time might not be strictly needed + // but keeping consistent for simplicity + staleTime: 30000, + gcTime: 5 * 60 * 1000, }); + // Refetch function targets the initial data query const refetch = useCallback( async (onSuccess?: () => void) => { try { - await refetchPositions(); - if (onSuccess) { - onSuccess(); - } + await refetchInitialData(); + onSuccess?.(); } catch (error) { - console.error('Error refetching positions:', error); + console.error('[Positions] Error during manual refetch:', error); } }, - [refetchPositions], + [refetchInitialData], ); - // Consider refetching true if either query is refetching - const isRefetching = isRefetchingPositions || isRefetchingEnhanced; + // Combine refetching states + const isRefetching = isRefetchingInitialData || isRefetchingEnhanced; return { data: enhancedPositions ?? [], - loading: isLoadingPositions, + loading: isLoadingInitialData, // Loading is determined by the first query isRefetching, - positionsError, + positionsError: initialError, // Error is determined by the first query refetch, }; }; diff --git a/src/hooks/useUserPositionsSummaryData.ts b/src/hooks/useUserPositionsSummaryData.ts index 7e1ee3af..2a8eaac0 100644 --- a/src/hooks/useUserPositionsSummaryData.ts +++ b/src/hooks/useUserPositionsSummaryData.ts @@ -83,9 +83,6 @@ const useUserPositionsSummaryData = (user: string | undefined) => { refetch: refetchPositions, } = useUserPositions(user, true); - console.log('positionsLoading', positionsLoading); - console.log('hasInitialData', hasInitialData); - const { fetchTransactions } = useUserTransactions(); // Query for block numbers - this runs once and is cached From 5510997cdfd2c5ef77b030c44f01aa26de61a875 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 00:10:03 +0800 Subject: [PATCH 12/20] feat: useUserPosition hook --- src/config/dataSources.ts | 8 +- src/data-sources/morpho-api/positions.ts | 79 +++++++--- src/data-sources/morpho-api/transactions.ts | 38 ++--- src/data-sources/subgraph/positions.ts | 164 ++++++++++++++++++++ src/graphql/morpho-subgraph-queries.ts | 18 +++ src/hooks/useUserPosition.ts | 164 ++++++++++++-------- 6 files changed, 354 insertions(+), 117 deletions(-) diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts index e27d8b67..f8e55e8c 100644 --- a/src/config/dataSources.ts +++ b/src/config/dataSources.ts @@ -5,10 +5,10 @@ import { SupportedNetworks } from '@/utils/networks'; */ export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { switch (network) { - // case SupportedNetworks.Mainnet: - // return 'subgraph'; - // case SupportedNetworks.Base: - // return 'subgraph'; + case SupportedNetworks.Mainnet: + return 'subgraph'; + case SupportedNetworks.Base: + return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/data-sources/morpho-api/positions.ts b/src/data-sources/morpho-api/positions.ts index bc032ce3..e6f62432 100644 --- a/src/data-sources/morpho-api/positions.ts +++ b/src/data-sources/morpho-api/positions.ts @@ -1,7 +1,8 @@ -import { userPositionsQuery } from '@/graphql/morpho-api-queries'; +import { userPositionsQuery, userPositionForMarketQuery } from '@/graphql/morpho-api-queries'; import { SupportedNetworks } from '@/utils/networks'; import { MarketPosition } from '@/utils/types'; import { URLS } from '@/utils/urls'; +import { morphoGraphqlFetcher } from './fetchers'; // Type for the raw response from the Morpho API userPositionsQuery type MorphoUserPositionsApiResponse = { @@ -13,6 +14,14 @@ type MorphoUserPositionsApiResponse = { errors?: { message: string }[]; }; +// Type for the raw response from the Morpho API userPositionForMarketQuery +type MorphoUserMarketPositionApiResponse = { + data?: { + marketPosition?: MarketPosition; + }; + errors?: { message: string }[]; +}; + // Type for a valid position with required fields type ValidMarketPosition = MarketPosition & { market: { @@ -29,27 +38,13 @@ export const fetchMorphoUserPositionMarkets = async ( network: SupportedNetworks, ): Promise<{ marketUniqueKey: string; chainId: number }[]> => { try { - const response = await fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: userPositionsQuery, - variables: { - address: userAddress.toLowerCase(), - chainId: network, - }, - }), - }); - - const result = (await response.json()) as MorphoUserPositionsApiResponse; - - if (result.errors) { - console.error( - `Morpho API error fetching position markets for ${userAddress} on ${network}:`, - result.errors, - ); - throw new Error(result.errors.map((e) => e.message).join('; ')); - } + const result = await morphoGraphqlFetcher( + userPositionsQuery, + { + address: userAddress.toLowerCase(), + chainId: network, + }, + ); const marketPositions = result.data?.userByAddress?.marketPositions ?? []; @@ -74,3 +69,43 @@ export const fetchMorphoUserPositionMarkets = async ( return []; // Return empty array on error } }; + +/** + * Fetches a user's position for a specific market directly from the Morpho API. + */ +export const fetchMorphoUserPositionForMarket = async ( + marketUniqueKey: string, + userAddress: string, + network: SupportedNetworks, +): Promise => { + try { + const result = await morphoGraphqlFetcher( + userPositionForMarketQuery, + { + address: userAddress.toLowerCase(), + chainId: network, + marketKey: marketUniqueKey, + }, + ); + + const marketPosition = result.data?.marketPosition; + + // Check if the position state has zero balances - API might return structure even with no actual position + if ( + marketPosition && + marketPosition.state.supplyAssets === '0' && + marketPosition.state.borrowAssets === '0' && + marketPosition.state.collateral === '0' + ) { + return null; // Treat zero balance position as null + } + + return marketPosition ?? null; + } catch (error) { + console.error( + `Failed to fetch position for market ${marketUniqueKey} from Morpho API for ${userAddress} on ${network}:`, + error, + ); + return null; // Return null on error + } +}; diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index a755c422..90068380 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -2,6 +2,15 @@ import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; import { TransactionFilters, TransactionResponse } from '@/hooks/useUserTransactions'; import { SupportedNetworks } from '@/utils/networks'; import { URLS } from '@/utils/urls'; +import { morphoGraphqlFetcher } from './fetchers'; + +// Define the expected shape of the GraphQL response for transactions +type MorphoTransactionsApiResponse = { + data?: { + transactions?: TransactionResponse; + }; + // errors are handled by the fetcher +}; export const fetchMorphoTransactions = async ( filters: TransactionFilters, @@ -30,29 +39,14 @@ export const fetchMorphoTransactions = async ( } try { - const response = await fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const result = await morphoGraphqlFetcher( + userTransactionsQuery, + { + where: whereClause, + first: filters.first ?? 1000, + skip: filters.skip ?? 0, }, - body: JSON.stringify({ - query: userTransactionsQuery, - variables: { - where: whereClause, // Use the conditionally built 'where' clause - first: filters.first ?? 1000, - skip: filters.skip ?? 0, - }, - }), - }); - - const result = (await response.json()) as { - data?: { transactions?: TransactionResponse }; - errors?: { message: string }[]; - }; - - if (result.errors) { - throw new Error(result.errors.map((e) => e.message).join(', ')); - } + ); const transactions = result.data?.transactions; if (!transactions) { diff --git a/src/data-sources/subgraph/positions.ts b/src/data-sources/subgraph/positions.ts index a795534e..a2164c83 100644 --- a/src/data-sources/subgraph/positions.ts +++ b/src/data-sources/subgraph/positions.ts @@ -1,6 +1,19 @@ +import { request } from 'graphql-request'; +import { fetchSubgraphMarket } from '@/data-sources/subgraph/market'; // Need market data too import { subgraphUserPositionMarketsQuery } from '@/graphql/morpho-subgraph-queries'; +import { subgraphUserMarketPositionQuery } from '@/graphql/morpho-subgraph-queries'; import { SupportedNetworks } from '@/utils/networks'; import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { MarketPosition } from '@/utils/types'; + +// The type expected by MarketPosition.state +type MarketPositionState = { + supplyShares: string; + supplyAssets: string; + borrowShares: string; + borrowAssets: string; + collateral: string; // This is collateral assets +}; type SubgraphPositionMarketResponse = { data?: { @@ -15,6 +28,20 @@ type SubgraphPositionMarketResponse = { errors?: { message: string }[]; }; +type SubgraphPosition = { + id: string; + asset: { + id: string; // Token address + }; + isCollateral: boolean | null; + balance: string; // BigInt string + side: 'SUPPLIER' | 'COLLATERAL' | 'BORROWER'; +}; + +type SubgraphPositionResponse = { + positions?: SubgraphPosition[]; +}; + /** * Fetches the unique keys of markets where a user has a position from the Subgraph. */ @@ -64,3 +91,140 @@ export const fetchSubgraphUserPositionMarkets = async ( return []; // Return empty array on error } }; + +/** + * Fetches and reconstructs a user's position for a specific market from the Subgraph. + * Combines position data with market data. + */ +export const fetchSubgraphUserPositionForMarket = async ( + marketUniqueKey: string, + userAddress: string, + network: SupportedNetworks, +): Promise => { + const subgraphUrl = getSubgraphUrl(network); + if (!subgraphUrl) { + console.error(`Subgraph URL not configured for network ${network}.`); + return null; + } + + try { + // 1. Fetch the market details first (needed for context) + const market = await fetchSubgraphMarket(marketUniqueKey, network); + if (!market) { + console.warn( + `Market ${marketUniqueKey} not found via subgraph on ${network} while fetching user position.`, + ); + return null; // Cannot proceed without market details + } + + // 2. Fetch the user's positions within that market + const response = await request( + subgraphUrl, + subgraphUserMarketPositionQuery, + { + marketId: marketUniqueKey.toLowerCase(), // Ensure lowercase for subgraph ID matching + userId: userAddress.toLowerCase(), + }, + ); + + const positions = response.positions ?? []; + + // 3. Reconstruct the MarketPosition.state object + let supplyShares = '0'; + let supplyAssets = '0'; + let borrowShares = '0'; + let borrowAssets = '0'; + let collateralAssets = '0'; + + positions.forEach((pos) => { + const balanceStr = pos.balance; + if (!balanceStr || balanceStr === '0') return; // Ignore zero/empty balances + + switch (pos.side) { + case 'SUPPLIER': + // Assuming the SUPPLIER asset is always the loan asset + if (pos.asset.id.toLowerCase() === market.loanAsset.address.toLowerCase()) { + // Subgraph returns shares for SUPPLIER side in `balance` + supplyShares = balanceStr; + // We also need supplyAssets. Subgraph might not directly provide this for the position. + // We might need to calculate it using market.state conversion rates, or rely on fetchPositionSnapshot. + // For now, let's assume fetchPositionSnapshot is the primary source for accurate assets. + // If falling back here, we might lack the direct asset value from subgraph. + // Let's set assets based on shares * rate, IF market state has the rates. + // This requires market.state.supplyAssets and market.state.supplyShares + const marketTotalSupplyAssets = BigInt(market.state.supplyAssets || '0'); + const marketTotalSupplyShares = BigInt(market.state.supplyShares || '1'); // Avoid div by zero + supplyAssets = + marketTotalSupplyShares > 0n + ? ( + (BigInt(supplyShares) * marketTotalSupplyAssets) / + marketTotalSupplyShares + ).toString() + : '0'; + } else { + console.warn( + `Subgraph position side 'SUPPLIER' doesn't match loan asset for market ${marketUniqueKey}`, + ); + } + break; + case 'COLLATERAL': + // Assuming the COLLATERAL asset is always the collateral asset + if (pos.asset.id.toLowerCase() === market.collateralAsset.address.toLowerCase()) { + // Subgraph 'balance' for collateral IS THE ASSET AMOUNT + collateralAssets = balanceStr; + } else { + console.warn( + `Subgraph position side 'COLLATERAL' doesn't match collateral asset for market ${marketUniqueKey}`, + ); + } + break; + case 'BORROWER': + // Assuming the BORROWER asset is always the loan asset + if (pos.asset.id.toLowerCase() === market.loanAsset.address.toLowerCase()) { + // Subgraph returns shares for BORROWER side in `balance` + borrowShares = balanceStr; + // Calculate borrowAssets from shares + const marketTotalBorrowAssets = BigInt(market.state.borrowAssets || '0'); + const marketTotalBorrowShares = BigInt(market.state.borrowShares || '1'); // Avoid div by zero + borrowAssets = + marketTotalBorrowShares > 0n + ? ( + (BigInt(borrowShares) * marketTotalBorrowAssets) / + marketTotalBorrowShares + ).toString() + : '0'; + } else { + console.warn( + `Subgraph position side 'BORROWER' doesn't match loan asset for market ${marketUniqueKey}`, + ); + } + break; + } + }); + + // Check if the user has any position (check assets) + if (supplyAssets === '0' && collateralAssets === '0' && borrowAssets === '0') { + // If all balances are zero, treat as no position found for this market + return null; // Return null as per MarketPosition type possibility + } + + const state: MarketPositionState = { + supplyAssets: supplyAssets, + supplyShares: supplyShares, + collateral: collateralAssets, // Use the direct asset amount + borrowAssets: borrowAssets, + borrowShares: borrowShares, + }; + + return { + market, + state: state, + }; + } catch (error) { + console.error( + `Failed to fetch user position for market ${marketUniqueKey} from Subgraph on ${network}:`, + error, + ); + return null; // Return null on error + } +}; diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts index b733ae8a..d99e3a75 100644 --- a/src/graphql/morpho-subgraph-queries.ts +++ b/src/graphql/morpho-subgraph-queries.ts @@ -271,6 +271,24 @@ export const subgraphUserPositionMarketsQuery = ` `; // --- End Query --- +// --- Query for User Position in a Single Market --- +export const subgraphUserMarketPositionQuery = ` + query GetUserMarketPosition($marketId: ID!, $userId: ID!) { + positions( + where: { market: $marketId, account: $userId } + ) { + id + asset { + id # Token address + } + isCollateral + balance + side # SUPPLIER, BORROWER, COLLATERAL + } + } +`; +// --- End Query --- + // Note: The exact field names might need adjustment based on the specific Subgraph schema. export const subgraphUserTransactionsQuery = ` query GetUserTransactions( diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index 6572fa6a..5efb6c47 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -1,92 +1,118 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Address } from 'viem'; -import { userPositionForMarketQuery } from '@/graphql/morpho-api-queries'; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoUserPositionForMarket } from '@/data-sources/morpho-api/positions'; +import { fetchSubgraphUserPositionForMarket } from '@/data-sources/subgraph/positions'; import { SupportedNetworks } from '@/utils/networks'; import { fetchPositionSnapshot } from '@/utils/positions'; import { MarketPosition } from '@/utils/types'; -import { URLS } from '@/utils/urls'; -const useUserPositions = ( +/** + * Hook to fetch a user's position in a specific market. + * + * Prioritizes the latest on-chain snapshot via `fetchPositionSnapshot`. + * Falls back to the configured data source (Morpho API or Subgraph) if the snapshot is unavailable. + * + * @param user The user's address. + * @param chainId The network ID. + * @param marketKey The unique key of the market. + * @returns User position data, loading state, error state, and refetch function. + */ +const useUserPosition = ( user: string | undefined, - chainId: SupportedNetworks, - marketKey: string, + chainId: SupportedNetworks | undefined, + marketKey: string | undefined, ) => { - const [loading, setLoading] = useState(true); - const [isRefetching, setIsRefetching] = useState(false); - const [position, setPosition] = useState(null); - const [positionsError, setPositionsError] = useState(null); + const queryKey = ['userPosition', user, chainId, marketKey]; - const fetchData = useCallback( - async (isRefetch = false, onSuccess?: () => void) => { - if (!user) { - console.error('Missing user address'); - setLoading(false); - setIsRefetching(false); - return; + const { data, isLoading, error, refetch, isRefetching } = useQuery< + MarketPosition | null, + unknown + >({ + queryKey: queryKey, + queryFn: async (): Promise => { + if (!user || !chainId || !marketKey) { + console.log('Missing user, chainId, or marketKey for useUserPosition'); + return null; } - try { - if (isRefetch) { - setIsRefetching(true); - } else { - setLoading(true); - } - - setPositionsError(null); + // 1. Try fetching the on-chain snapshot first + console.log(`Attempting fetchPositionSnapshot for ${user} on market ${marketKey}`); + const snapshot = await fetchPositionSnapshot(marketKey, user as Address, chainId, 0); - // Fetch position data from both networks - const res = await fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - query: userPositionForMarketQuery, - variables: { - address: user.toLowerCase(), - chainId: chainId, - marketKey, - }, - }), - }); - - const data = (await res.json()) as { data: { marketPosition: MarketPosition } }; + if (snapshot) { + // If snapshot has zero balances, treat as null position early + if ( + snapshot.supplyAssets === '0' && + snapshot.borrowAssets === '0' && + snapshot.collateral === '0' + ) { + console.log( + `Snapshot shows zero balance for ${user} on market ${marketKey}, returning null.`, + ); + return null; + } + } - // Read on-chain data - const currentSnapshot = await fetchPositionSnapshot(marketKey, user as Address, chainId, 0); + // 2. Determine fallback data source + const dataSource = getMarketDataSource(chainId); + console.log(`Fallback data source for ${chainId}: ${dataSource}`); - if (currentSnapshot) { - setPosition({ - market: data.data.marketPosition.market, - state: currentSnapshot, - }); - } else { - setPosition(data.data.marketPosition); + // 3. Fetch from the determined data source + let positionData: MarketPosition | null = null; + try { + if (dataSource === 'morpho') { + positionData = await fetchMorphoUserPositionForMarket(marketKey, user, chainId); + } else if (dataSource === 'subgraph') { + positionData = await fetchSubgraphUserPositionForMarket(marketKey, user, chainId); } + } catch (fetchError) { + console.error( + `Failed to fetch user position via fallback (${dataSource}) for ${user} on market ${marketKey}:`, + fetchError, + ); + return null; // Return null on error during fallback + } - onSuccess?.(); - } catch (err) { - console.error('Error fetching positions:', err); - setPositionsError(err); - } finally { - setLoading(false); - setIsRefetching(false); + // If we got a snapshot earlier, overwrite the state from the fallback with the fresh snapshot state + // Ensure the structure matches MarketPosition.state + if (snapshot && positionData) { + console.log(`Overwriting fallback state with fresh snapshot state for ${marketKey}`); + positionData.state = { + supplyAssets: snapshot.supplyAssets.toString(), + supplyShares: snapshot.supplyShares.toString(), + borrowAssets: snapshot.borrowAssets.toString(), + borrowShares: snapshot.borrowShares.toString(), + collateral: snapshot.collateral, + }; + } else if (snapshot && !positionData) { + // If snapshot exists but fallback failed, we cannot construct MarketPosition + console.warn( + `Snapshot existed but fallback failed for ${marketKey}, cannot return full MarketPosition.`, + ); + return null; } - }, - [user, chainId, marketKey], - ); - useEffect(() => { - void fetchData(); - }, [fetchData]); + console.log( + `Final position data for ${user} on market ${marketKey}:`, + positionData ? 'Found' : 'Not Found', + ); + return positionData; // This will be null if neither snapshot nor fallback worked, or if balances were zero + }, + enabled: !!user && !!chainId && !!marketKey, + staleTime: 1000 * 60 * 1, // Stale after 1 minute + refetchInterval: 1000 * 60 * 5, // Refetch every 5 minutes + placeholderData: (previousData) => previousData ?? null, + retry: 1, // Retry once on error + }); return { - position, - loading, + position: data, + loading: isLoading, isRefetching, - positionsError, - refetch: (onSuccess?: () => void) => void fetchData(true, onSuccess), + error, + refetch, }; }; -export default useUserPositions; +export default useUserPosition; From 7ae0821d96b9d8bcbdd07902c07d98a103ee3c02 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 00:17:47 +0800 Subject: [PATCH 13/20] chore: lint and fix util rates --- app/market/[chainId]/[marketid]/RateChart.tsx | 2 ++ src/data-sources/morpho-api/positions.ts | 12 ++++-------- src/data-sources/morpho-api/transactions.ts | 1 - src/data-sources/subgraph/market.ts | 2 +- src/hooks/useUserPosition.ts | 2 +- 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/market/[chainId]/[marketid]/RateChart.tsx b/app/market/[chainId]/[marketid]/RateChart.tsx index ea60009c..0f683cc8 100644 --- a/app/market/[chainId]/[marketid]/RateChart.tsx +++ b/app/market/[chainId]/[marketid]/RateChart.tsx @@ -54,6 +54,8 @@ function RateChart({ })); }; + console.log('market', market); + const formatPercentage = (value: number) => `${(value * 100).toFixed(2)}%`; const getCurrentApyValue = (type: 'supply' | 'borrow') => { diff --git a/src/data-sources/morpho-api/positions.ts b/src/data-sources/morpho-api/positions.ts index e6f62432..61a8fb06 100644 --- a/src/data-sources/morpho-api/positions.ts +++ b/src/data-sources/morpho-api/positions.ts @@ -1,7 +1,6 @@ import { userPositionsQuery, userPositionForMarketQuery } from '@/graphql/morpho-api-queries'; import { SupportedNetworks } from '@/utils/networks'; import { MarketPosition } from '@/utils/types'; -import { URLS } from '@/utils/urls'; import { morphoGraphqlFetcher } from './fetchers'; // Type for the raw response from the Morpho API userPositionsQuery @@ -38,13 +37,10 @@ export const fetchMorphoUserPositionMarkets = async ( network: SupportedNetworks, ): Promise<{ marketUniqueKey: string; chainId: number }[]> => { try { - const result = await morphoGraphqlFetcher( - userPositionsQuery, - { - address: userAddress.toLowerCase(), - chainId: network, - }, - ); + const result = await morphoGraphqlFetcher(userPositionsQuery, { + address: userAddress.toLowerCase(), + chainId: network, + }); const marketPositions = result.data?.userByAddress?.marketPositions ?? []; diff --git a/src/data-sources/morpho-api/transactions.ts b/src/data-sources/morpho-api/transactions.ts index 90068380..20e3e77a 100644 --- a/src/data-sources/morpho-api/transactions.ts +++ b/src/data-sources/morpho-api/transactions.ts @@ -1,7 +1,6 @@ import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; import { TransactionFilters, TransactionResponse } from '@/hooks/useUserTransactions'; import { SupportedNetworks } from '@/utils/networks'; -import { URLS } from '@/utils/urls'; import { morphoGraphqlFetcher } from './fetchers'; // Define the expected shape of the GraphQL response for transactions diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 90686e9e..7ff41293 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -142,7 +142,7 @@ const transformSubgraphMarketToMarket = ( const totalSupplyNum = safeParseFloat(supplyAssets); const totalBorrowNum = safeParseFloat(borrowAssets); - const utilization = totalSupplyNum > 0 ? (totalBorrowNum / totalSupplyNum) * 100 : 0; + const utilization = totalSupplyNum > 0 ? totalBorrowNum / totalSupplyNum : 0; const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0); const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0); diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index 5efb6c47..e3c5c234 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -107,7 +107,7 @@ const useUserPosition = ( }); return { - position: data, + position: data ?? null, loading: isLoading, isRefetching, error, From 168408b7551d9eb18c240acea68a20abccf32c2a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 00:34:25 +0800 Subject: [PATCH 14/20] fix: empty markets --- src/data-sources/subgraph/market.ts | 11 ++++++----- src/data-sources/subgraph/queries.ts | 0 2 files changed, 6 insertions(+), 5 deletions(-) delete mode 100644 src/data-sources/subgraph/queries.ts diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 7ff41293..347c720c 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -93,6 +93,10 @@ const transformSubgraphMarketToMarket = ( const irmAddress = subgraphMarket.irm ?? '0x'; const inputTokenPriceUSD = subgraphMarket.inputTokenPriceUSD ?? '0'; + if (marketId.toLowerCase() === '0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836') { + console.log('subgraphMarket', subgraphMarket) + } + const totalBorrowBalanceUSD = subgraphMarket.totalBorrowBalanceUSD ?? '0'; const totalSupplyShares = subgraphMarket.totalSupplyShares ?? '0'; const totalBorrowShares = subgraphMarket.totalBorrowShares ?? '0'; @@ -147,9 +151,6 @@ const transformSubgraphMarketToMarket = ( const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0); const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0); - // only borrowBalanceUSD is available in subgraph, we need to calculate supplyAssetsUsd, liquidityAssetsUsd, collateralAssetsUsd - const borrowAssetsUsd = safeParseFloat(totalBorrowBalanceUSD); - // get the prices let loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); let collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0'); @@ -170,6 +171,7 @@ const transformSubgraphMarketToMarket = ( } const supplyAssetsUsd = formatBalance(supplyAssets, loanAsset.decimals) * loanAssetPrice; + const borrowAssetsUsd = formatBalance(borrowAssets, loanAsset.decimals) * loanAssetPrice; const liquidityAssets = (BigInt(supplyAssets) - BigInt(borrowAssets)).toString(); const liquidityAssetsUsd = formatBalance(liquidityAssets, loanAsset.decimals) * loanAssetPrice; @@ -287,7 +289,7 @@ export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise< const variables: SubgraphMarketsVariables = { first: 1000, // Max limit where: { - inputToken_not_in: blacklistTokens, + inputToken_not_in: [...blacklistTokens, '0x0000000000000000000000000000000000000000'], }, }; @@ -308,7 +310,6 @@ export const fetchSubgraphMarkets = async (network: SupportedNetworks): Promise< // Fetch major prices *once* before transforming all markets const majorPrices = await fetchLocalMajorPrices(); - // Transform each market using the fetched prices return marketsData.map((market) => transformSubgraphMarketToMarket(market, network, majorPrices)); }; diff --git a/src/data-sources/subgraph/queries.ts b/src/data-sources/subgraph/queries.ts deleted file mode 100644 index e69de29b..00000000 From b5daa9efe7db36787f131efc3db4ec7ea771043d Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 00:41:05 +0800 Subject: [PATCH 15/20] feat: lacking data source for oracles --- src/config/dataSources.ts | 8 +++---- src/data-sources/subgraph/market.ts | 5 +++-- src/utils/warnings.ts | 33 +++++++---------------------- 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts index f8e55e8c..e27d8b67 100644 --- a/src/config/dataSources.ts +++ b/src/config/dataSources.ts @@ -5,10 +5,10 @@ import { SupportedNetworks } from '@/utils/networks'; */ export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { switch (network) { - case SupportedNetworks.Mainnet: - return 'subgraph'; - case SupportedNetworks.Base: - return 'subgraph'; + // case SupportedNetworks.Mainnet: + // return 'subgraph'; + // case SupportedNetworks.Base: + // return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 347c720c..830feac1 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -21,6 +21,7 @@ import { } from '@/utils/tokens'; import { WarningWithDetail, MorphoChainlinkOracleData, Market } from '@/utils/types'; import { subgraphGraphqlFetcher } from './fetchers'; +import { getMarketWarningsWithDetail, subgraphDefaultWarnings } from '@/utils/warnings'; // Define the structure for the fetched prices locally type LocalMajorPrices = { @@ -179,7 +180,7 @@ const transformSubgraphMarketToMarket = ( const collateralAssetsUsd = formatBalance(collateralAssets, collateralAsset.decimals) * collateralAssetPrice; - const warningsWithDetail: WarningWithDetail[] = []; // Subgraph doesn't provide warnings directly + const warningsWithDetail = getMarketWarningsWithDetail({warnings:subgraphDefaultWarnings}); const marketDetail: Market = { id: marketId, @@ -219,7 +220,7 @@ const transformSubgraphMarketToMarket = ( id: chainId, }, }, - warnings: [], // Subgraph doesn't provide warnings + warnings: subgraphDefaultWarnings, warningsWithDetail: warningsWithDetail, oracle: { data: defaultOracleData, // Placeholder oracle data diff --git a/src/utils/warnings.ts b/src/utils/warnings.ts index 1e645d1b..6f7a2438 100644 --- a/src/utils/warnings.ts +++ b/src/utils/warnings.ts @@ -1,6 +1,14 @@ import { MarketWarning } from '@/utils/types'; import { WarningCategory, WarningWithDetail } from './types'; +export const subgraphDefaultWarnings: MarketWarning[] = [ + { + type: 'unrecognized_oracle', + level: 'alert', + __typename: 'OracleWarning_MonarchAttached', + }, +]; + const morphoOfficialWarnings: WarningWithDetail[] = [ { code: 'hardcoded_oracle', @@ -102,30 +110,5 @@ export const getMarketWarningsWithDetail = (market: { warnings: MarketWarning[] result.push(foundWarning); } } - - // ====================== - // Add Extra warnings - // ====================== - - // bad debt warnings - // if (market.badDebt && market.badDebt.usd > 0) { - // const warning = morphoOfficialWarnings.find((w) => w.code === 'bad_debt_unrealized'); - // if (warning) { - // if (Number(market.badDebt.usd) > 0.01 * Number(market.state.supplyAssetsUsd)) { - // warning.level = 'alert'; - // } - // result.push(warning); - // } - // } - // if (market.realizedBadDebt && market.realizedBadDebt.usd > 0) { - // const warning = morphoOfficialWarnings.find((w) => w.code === 'bad_debt_realized'); - // if (warning) { - // if (Number(market.realizedBadDebt.usd) > 0.01 * Number(market.state.supplyAssetsUsd)) { - // warning.level = 'alert'; - // } - // result.push(warning); - // } - // } - return result; }; From 15082c1a9fdab372330bc2632bdb97c39c994633 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 01:12:13 +0800 Subject: [PATCH 16/20] fix: default oracle to showing unknown instaed of no oracle --- src/data-sources/subgraph/market.ts | 51 +++++++++++++++++++------ src/utils/oracle.ts | 1 + src/utils/warnings.ts | 58 +++++++++++++++++++++++++---- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 830feac1..7fb1012b 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -1,4 +1,4 @@ -import { Address } from 'viem'; +import { Address, zeroAddress } from 'viem'; import { marketQuery as subgraphMarketQuery, marketsQuery as subgraphMarketsQuery, @@ -19,9 +19,15 @@ import { UnknownERC20Token, TokenPeg, } from '@/utils/tokens'; -import { WarningWithDetail, MorphoChainlinkOracleData, Market } from '@/utils/types'; +import { MorphoChainlinkOracleData, Market } from '@/utils/types'; +import { + getMarketWarningsWithDetail, + SUBGRAPH_NO_ORACLE, + SUBGRAPH_NO_PRICE, + UNRECOGNIZED_COLLATERAL, + UNRECOGNIZED_LOAN, +} from '@/utils/warnings'; import { subgraphGraphqlFetcher } from './fetchers'; -import { getMarketWarningsWithDetail, subgraphDefaultWarnings } from '@/utils/warnings'; // Define the structure for the fetched prices locally type LocalMajorPrices = { @@ -94,11 +100,12 @@ const transformSubgraphMarketToMarket = ( const irmAddress = subgraphMarket.irm ?? '0x'; const inputTokenPriceUSD = subgraphMarket.inputTokenPriceUSD ?? '0'; - if (marketId.toLowerCase() === '0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836') { - console.log('subgraphMarket', subgraphMarket) + if ( + marketId.toLowerCase() === '0x9103c3b4e834476c9a62ea009ba2c884ee42e94e6e314a26f04d312434191836' + ) { + console.log('subgraphMarket', subgraphMarket); } - const totalBorrowBalanceUSD = subgraphMarket.totalBorrowBalanceUSD ?? '0'; const totalSupplyShares = subgraphMarket.totalSupplyShares ?? '0'; const totalBorrowShares = subgraphMarket.totalBorrowShares ?? '0'; const fee = subgraphMarket.fee ?? '0'; @@ -129,7 +136,16 @@ const transformSubgraphMarketToMarket = ( const collateralAsset = mapToken(subgraphMarket.inputToken); const defaultOracleData: MorphoChainlinkOracleData = { - baseFeedOne: null, + baseFeedOne: { + address: zeroAddress, + chain: { + id: network, + }, + description: null, + id: zeroAddress, + pair: null, + vendor: 'Unknown', + }, baseFeedTwo: null, quoteFeedOne: null, quoteFeedTwo: null, @@ -152,23 +168,34 @@ const transformSubgraphMarketToMarket = ( const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0); const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0); + const warnings = [SUBGRAPH_NO_ORACLE]; + // get the prices let loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); let collateralAssetPrice = safeParseFloat(subgraphMarket.inputToken?.lastPriceUSD ?? '0'); // @todo: might update due to input token being used here const hasUSDPrice = loanAssetPrice > 0 && collateralAssetPrice > 0; + + const knownLoadAsset = findToken(loanAsset.address, network); + const knownCollateralAsset = findToken(collateralAsset.address, network); + + if (!knownLoadAsset) { + warnings.push(UNRECOGNIZED_LOAN); + } + if (!knownCollateralAsset) { + warnings.push(UNRECOGNIZED_COLLATERAL); + } + if (!hasUSDPrice) { // no price available, try to estimate - - const knownLoadAsset = findToken(loanAsset.address, network); if (knownLoadAsset) { loanAssetPrice = getEstimateValue(knownLoadAsset) ?? 0; } - const knownCollateralAsset = findToken(collateralAsset.address, network); if (knownCollateralAsset) { collateralAssetPrice = getEstimateValue(knownCollateralAsset) ?? 0; } + warnings.push(SUBGRAPH_NO_PRICE); } const supplyAssetsUsd = formatBalance(supplyAssets, loanAsset.decimals) * loanAssetPrice; @@ -180,7 +207,7 @@ const transformSubgraphMarketToMarket = ( const collateralAssetsUsd = formatBalance(collateralAssets, collateralAsset.decimals) * collateralAssetPrice; - const warningsWithDetail = getMarketWarningsWithDetail({warnings:subgraphDefaultWarnings}); + const warningsWithDetail = getMarketWarningsWithDetail({ warnings }); const marketDetail: Market = { id: marketId, @@ -220,7 +247,7 @@ const transformSubgraphMarketToMarket = ( id: chainId, }, }, - warnings: subgraphDefaultWarnings, + warnings: warnings, warningsWithDetail: warningsWithDetail, oracle: { data: defaultOracleData, // Placeholder oracle data diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index a3dd0496..c1863a8f 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -27,6 +27,7 @@ export const OracleVendorIcons: Record = { export function parseOracleVendors(oracleData: MorphoChainlinkOracleData | null): VendorInfo { if (!oracleData) return { vendors: [], isUnknown: false }; + if ( !oracleData.baseFeedOne && !oracleData.baseFeedTwo && diff --git a/src/utils/warnings.ts b/src/utils/warnings.ts index 6f7a2438..17181ab6 100644 --- a/src/utils/warnings.ts +++ b/src/utils/warnings.ts @@ -1,13 +1,37 @@ import { MarketWarning } from '@/utils/types'; import { WarningCategory, WarningWithDetail } from './types'; -export const subgraphDefaultWarnings: MarketWarning[] = [ - { - type: 'unrecognized_oracle', - level: 'alert', - __typename: 'OracleWarning_MonarchAttached', - }, -]; +// Subgraph Warnings + +// Default subrgaph has no oracle data attached! +export const SUBGRAPH_NO_ORACLE = { + type: 'subgraph_unrecognized_oracle', + level: 'alert', + __typename: 'OracleWarning_MonarchAttached', +}; + +// Most subgraph markets has no price data +export const SUBGRAPH_NO_PRICE = { + type: 'subgraph_no_price', + level: 'warning', + __typename: 'MarketWarning_SubgraphNoPrice', +}; + +export const subgraphDefaultWarnings: MarketWarning[] = [SUBGRAPH_NO_ORACLE]; + +export const UNRECOGNIZED_LOAN = { + type: 'unrecognized_loan_asset', + level: 'alert', + __typename: 'MarketWarning_UnrecognizedLoanAsset', +}; + +export const UNRECOGNIZED_COLLATERAL = { + type: 'unrecognized_collateral_asset', + level: 'alert', + __typename: 'MarketWarning_UnrecognizedCollateralAsset', +}; + +// Morpho Official Warnings const morphoOfficialWarnings: WarningWithDetail[] = [ { @@ -100,12 +124,30 @@ const morphoOfficialWarnings: WarningWithDetail[] = [ }, ]; +const subgraphWarnings: WarningWithDetail[] = [ + { + code: 'subgraph_unrecognized_oracle', + level: 'alert', + description: + 'The underlying data source (subgraph) does not provide any details on this oralce address.', + category: WarningCategory.oracle, + }, + { + code: 'subgraph_no_price', + level: 'warning', + description: 'The USD value of the market is estimated with an offchain price source.', + category: WarningCategory.general, + }, +]; + export const getMarketWarningsWithDetail = (market: { warnings: MarketWarning[] }) => { const result = []; + const allDetails = [...morphoOfficialWarnings, ...subgraphWarnings]; + // process official warnings for (const warning of market.warnings) { - const foundWarning = morphoOfficialWarnings.find((w) => w.code === warning.type); + const foundWarning = allDetails.find((w) => w.code === warning.type); if (foundWarning) { result.push(foundWarning); } From 846f502c0a1343f37133c43bde10c52b4cc2ee0b Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 11:05:53 +0800 Subject: [PATCH 17/20] misc: fix type --- app/markets/components/MarketTableUtils.tsx | 2 +- app/markets/components/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/markets/components/MarketTableUtils.tsx b/app/markets/components/MarketTableUtils.tsx index 04f296e9..b26609cd 100644 --- a/app/markets/components/MarketTableUtils.tsx +++ b/app/markets/components/MarketTableUtils.tsx @@ -79,7 +79,7 @@ export function TDTotalSupplyOrBorrow({ symbol, }: { dataLabel: string; - assetsUSD: string; + assetsUSD: number; assets: string; decimals: number; symbol: string; diff --git a/app/markets/components/utils.ts b/app/markets/components/utils.ts index aab898b0..97add4ba 100644 --- a/app/markets/components/utils.ts +++ b/app/markets/components/utils.ts @@ -109,8 +109,8 @@ export function applyFilterAndSort( } // Add USD Filters - const supplyUsd = parseUsdValue(market.state?.supplyAssetsUsd); // Use optional chaining - const borrowUsd = parseUsdValue(market.state?.borrowAssetsUsd); // Use optional chaining + const supplyUsd = parseUsdValue(market.state?.supplyAssetsUsd.toString()); // Use optional chaining + const borrowUsd = parseUsdValue(market.state?.borrowAssetsUsd.toString()); // Use optional chaining if (minSupplyUsd !== null && (supplyUsd === null || supplyUsd < minSupplyUsd)) { return false; From 21d3b52f325bafebb062c9b8df32a3a89db88ea7 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 11:12:32 +0800 Subject: [PATCH 18/20] fix: refetch --- src/data-sources/subgraph/market.ts | 2 +- src/hooks/useUserPosition.ts | 24 ++++++++++++++++++++---- src/utils/oracle.ts | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 7fb1012b..4ffd9d12 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -136,7 +136,7 @@ const transformSubgraphMarketToMarket = ( const collateralAsset = mapToken(subgraphMarket.inputToken); const defaultOracleData: MorphoChainlinkOracleData = { - baseFeedOne: { + baseFeedOne: { address: zeroAddress, chain: { id: network, diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index e3c5c234..57c2a4f4 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -25,10 +25,13 @@ const useUserPosition = ( ) => { const queryKey = ['userPosition', user, chainId, marketKey]; - const { data, isLoading, error, refetch, isRefetching } = useQuery< - MarketPosition | null, - unknown - >({ + const { + data, + isLoading, + error, + refetch: refetchQuery, + isRefetching, + } = useQuery({ queryKey: queryKey, queryFn: async (): Promise => { if (!user || !chainId || !marketKey) { @@ -106,6 +109,19 @@ const useUserPosition = ( retry: 1, // Retry once on error }); + // refetch with onsuccess callback + const refetch = (onSuccess?: () => void) => { + refetchQuery() + .then(() => { + // Call onSuccess callback if provided after successful refetch + onSuccess?.(); + }) + .catch((err) => { + // Optional: Log error during refetch, but don't trigger onSuccess + console.error('Error during refetch triggered by refetch function:', err); + }); + }; + return { position: data ?? null, loading: isLoading, diff --git a/src/utils/oracle.ts b/src/utils/oracle.ts index c1863a8f..9caf6232 100644 --- a/src/utils/oracle.ts +++ b/src/utils/oracle.ts @@ -27,7 +27,7 @@ export const OracleVendorIcons: Record = { export function parseOracleVendors(oracleData: MorphoChainlinkOracleData | null): VendorInfo { if (!oracleData) return { vendors: [], isUnknown: false }; - + if ( !oracleData.baseFeedOne && !oracleData.baseFeedTwo && From ea3f393edc0853249b3d7262ddd5b1a889ac8bcf Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 11:28:13 +0800 Subject: [PATCH 19/20] chore: fix useUserPosition --- src/config/dataSources.ts | 8 +- src/data-sources/subgraph/liquidations.ts | 41 +++---- src/hooks/useUserPosition.ts | 143 ++++++++++++++-------- 3 files changed, 117 insertions(+), 75 deletions(-) diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts index e27d8b67..f8e55e8c 100644 --- a/src/config/dataSources.ts +++ b/src/config/dataSources.ts @@ -5,10 +5,10 @@ import { SupportedNetworks } from '@/utils/networks'; */ export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { switch (network) { - // case SupportedNetworks.Mainnet: - // return 'subgraph'; - // case SupportedNetworks.Base: - // return 'subgraph'; + case SupportedNetworks.Mainnet: + return 'subgraph'; + case SupportedNetworks.Base: + return 'subgraph'; default: return 'morpho'; // Default to Morpho API } diff --git a/src/data-sources/subgraph/liquidations.ts b/src/data-sources/subgraph/liquidations.ts index 3b256473..10bcd987 100644 --- a/src/data-sources/subgraph/liquidations.ts +++ b/src/data-sources/subgraph/liquidations.ts @@ -29,31 +29,31 @@ export const fetchSubgraphLiquidatedMarketKeys = async ( const liquidatedKeys = new Set(); // Apply the same base filters as fetchSubgraphMarkets - const variables = { - first: 1000, // Fetch in batches if necessary, though unlikely needed just for IDs - where: { - inputToken_not_in: blacklistTokens, - }, - }; - - try { - // Subgraph might paginate; handle if necessary, but 1000 limit is often sufficient for just IDs - const response = await subgraphGraphqlFetcher( + // paginate until the API returns < pageSize items + const pageSize = 1000; + let skip = 0; + while (true) { + const variables = { + first: pageSize, + skip, + where: { inputToken_not_in: blacklistTokens }, + }; + const page = await subgraphGraphqlFetcher( subgraphApiUrl, subgraphMarketsWithLiquidationCheckQuery, variables, ); - if (response.errors) { - console.error('GraphQL errors:', response.errors); + if (page.errors) { + console.error('GraphQL errors:', page.errors); throw new Error(`GraphQL error fetching liquidated market keys for network ${network}`); } - const markets = response.data?.markets; + const markets = page.data?.markets; if (!markets) { - console.warn(`No market data returned for liquidation check on network ${network}.`); - return liquidatedKeys; // Return empty set + console.warn(`No market data returned for liquidation check on network ${network} at skip ${skip}.`); + break; // Exit loop if no markets are returned } markets.forEach((market) => { @@ -62,12 +62,11 @@ export const fetchSubgraphLiquidatedMarketKeys = async ( liquidatedKeys.add(market.id); } }); - } catch (error) { - console.error( - `Error fetching liquidated market keys via Subgraph for network ${network}:`, - error, - ); - throw error; // Re-throw + + if (markets.length < pageSize) { + break; // Exit loop if the number of returned markets is less than the page size + } + skip += pageSize; } console.log(`Fetched ${liquidatedKeys.size} liquidated market keys via Subgraph for ${network}.`); diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index 57c2a4f4..ba02f8e4 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -6,6 +6,7 @@ import { fetchSubgraphUserPositionForMarket } from '@/data-sources/subgraph/posi import { SupportedNetworks } from '@/utils/networks'; import { fetchPositionSnapshot } from '@/utils/positions'; import { MarketPosition } from '@/utils/types'; +import { useMarkets } from './useMarkets'; /** * Hook to fetch a user's position in a specific market. @@ -25,6 +26,8 @@ const useUserPosition = ( ) => { const queryKey = ['userPosition', user, chainId, marketKey]; + const { markets } = useMarkets() + const { data, isLoading, @@ -41,66 +44,106 @@ const useUserPosition = ( // 1. Try fetching the on-chain snapshot first console.log(`Attempting fetchPositionSnapshot for ${user} on market ${marketKey}`); - const snapshot = await fetchPositionSnapshot(marketKey, user as Address, chainId, 0); - - if (snapshot) { - // If snapshot has zero balances, treat as null position early - if ( - snapshot.supplyAssets === '0' && - snapshot.borrowAssets === '0' && - snapshot.collateral === '0' - ) { - console.log( - `Snapshot shows zero balance for ${user} on market ${marketKey}, returning null.`, - ); - return null; - } - } - - // 2. Determine fallback data source - const dataSource = getMarketDataSource(chainId); - console.log(`Fallback data source for ${chainId}: ${dataSource}`); - - // 3. Fetch from the determined data source - let positionData: MarketPosition | null = null; + let snapshot = null; try { - if (dataSource === 'morpho') { - positionData = await fetchMorphoUserPositionForMarket(marketKey, user, chainId); - } else if (dataSource === 'subgraph') { - positionData = await fetchSubgraphUserPositionForMarket(marketKey, user, chainId); - } - } catch (fetchError) { + snapshot = await fetchPositionSnapshot(marketKey, user as Address, chainId, 0); + console.log(`Snapshot result for ${marketKey}:`, snapshot ? 'Exists' : 'Null'); + } catch (snapshotError) { console.error( - `Failed to fetch user position via fallback (${dataSource}) for ${user} on market ${marketKey}:`, - fetchError, + `Error fetching position snapshot for ${user} on market ${marketKey}:`, + snapshotError, ); - return null; // Return null on error during fallback + // Snapshot fetch failed, will proceed to fallback fetch } - // If we got a snapshot earlier, overwrite the state from the fallback with the fresh snapshot state - // Ensure the structure matches MarketPosition.state - if (snapshot && positionData) { - console.log(`Overwriting fallback state with fresh snapshot state for ${marketKey}`); - positionData.state = { - supplyAssets: snapshot.supplyAssets.toString(), - supplyShares: snapshot.supplyShares.toString(), - borrowAssets: snapshot.borrowAssets.toString(), - borrowShares: snapshot.borrowShares.toString(), - collateral: snapshot.collateral, - }; - } else if (snapshot && !positionData) { - // If snapshot exists but fallback failed, we cannot construct MarketPosition - console.warn( - `Snapshot existed but fallback failed for ${marketKey}, cannot return full MarketPosition.`, - ); - return null; + let finalPosition: MarketPosition | null = null; + + if (snapshot) { + // Snapshot succeeded, try to use local market data first + const market = markets?.find((m) => m.uniqueKey === marketKey); + + if (market) { + // Local market data found, construct position directly + console.log(`Found local market data for ${marketKey}, constructing position from snapshot.`); + finalPosition = { + market: market, + state: { // Add state from snapshot + supplyAssets: snapshot.supplyAssets.toString(), + supplyShares: snapshot.supplyShares.toString(), + borrowAssets: snapshot.borrowAssets.toString(), + borrowShares: snapshot.borrowShares.toString(), + collateral: snapshot.collateral, + }, + }; + } else { + // Local market data NOT found, need to fetch from fallback to get structure + console.warn( + `Local market data not found for ${marketKey}. Fetching from fallback source to combine with snapshot.`, + ); + const dataSource = getMarketDataSource(chainId); + let fallbackPosition: MarketPosition | null = null; + try { + if (dataSource === 'morpho') { + fallbackPosition = await fetchMorphoUserPositionForMarket(marketKey, user, chainId); + } else if (dataSource === 'subgraph') { + fallbackPosition = await fetchSubgraphUserPositionForMarket(marketKey, user, chainId); + } + if (fallbackPosition) { + // Fallback succeeded, combine with snapshot state + finalPosition = { + ...fallbackPosition, + state: { + supplyAssets: snapshot.supplyAssets.toString(), + supplyShares: snapshot.supplyShares.toString(), + borrowAssets: snapshot.borrowAssets.toString(), + borrowShares: snapshot.borrowShares.toString(), + collateral: snapshot.collateral, + }, + }; + } else { + // Fallback failed even though snapshot existed + console.error( + `Snapshot exists for ${marketKey}, but fallback fetch failed. Cannot return full position.`, + ); + finalPosition = null; + } + } catch (fetchError) { + console.error( + `Failed to fetch user position via fallback (${dataSource}) for ${user} on market ${marketKey} after snapshot success:`, + fetchError, + ); + finalPosition = null; + } + } + } else { + // Snapshot failed, rely entirely on the fallback data source + console.log(`Snapshot failed for ${marketKey}, fetching from fallback source.`); + const dataSource = getMarketDataSource(chainId); + try { + if (dataSource === 'morpho') { + finalPosition = await fetchMorphoUserPositionForMarket(marketKey, user, chainId); + } else if (dataSource === 'subgraph') { + finalPosition = await fetchSubgraphUserPositionForMarket(marketKey, user, chainId); + } + console.log( + `Fallback fetch result (after snapshot failure) for ${marketKey}:`, + finalPosition ? 'Found' : 'Not Found', + ); + } catch (fetchError) { + console.error( + `Failed to fetch user position via fallback (${dataSource}) for ${user} on market ${marketKey}:`, + fetchError, + ); + finalPosition = null; // Ensure null on error + } } console.log( `Final position data for ${user} on market ${marketKey}:`, - positionData ? 'Found' : 'Not Found', + finalPosition ? 'Found' : 'Not Found', ); - return positionData; // This will be null if neither snapshot nor fallback worked, or if balances were zero + // If finalPosition has zero balances, it's still a valid position state from the snapshot or fallback + return finalPosition; }, enabled: !!user && !!chainId && !!marketKey, staleTime: 1000 * 60 * 1, // Stale after 1 minute From b2e50b2b00cd820a9e36e89fa85f41864b47bbb8 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 27 Apr 2025 11:33:11 +0800 Subject: [PATCH 20/20] chore: review fixes --- src/data-sources/subgraph/liquidations.ts | 4 +++- src/data-sources/subgraph/market.ts | 13 ++++++++++--- src/hooks/useUserPosition.ts | 9 ++++++--- src/hooks/useUserPositions.ts | 9 ++++++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/data-sources/subgraph/liquidations.ts b/src/data-sources/subgraph/liquidations.ts index 10bcd987..44d309fd 100644 --- a/src/data-sources/subgraph/liquidations.ts +++ b/src/data-sources/subgraph/liquidations.ts @@ -52,7 +52,9 @@ export const fetchSubgraphLiquidatedMarketKeys = async ( const markets = page.data?.markets; if (!markets) { - console.warn(`No market data returned for liquidation check on network ${network} at skip ${skip}.`); + console.warn( + `No market data returned for liquidation check on network ${network} at skip ${skip}.`, + ); break; // Exit loop if no markets are returned } diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts index 4ffd9d12..141d8fa2 100644 --- a/src/data-sources/subgraph/market.ts +++ b/src/data-sources/subgraph/market.ts @@ -19,7 +19,7 @@ import { UnknownERC20Token, TokenPeg, } from '@/utils/tokens'; -import { MorphoChainlinkOracleData, Market } from '@/utils/types'; +import { MorphoChainlinkOracleData, Market, MarketWarning } from '@/utils/types'; import { getMarketWarningsWithDetail, SUBGRAPH_NO_ORACLE, @@ -46,7 +46,11 @@ const COINGECKO_API_URL = 'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd'; // Fetcher for major prices needed for estimation +const priceCache: { data?: LocalMajorPrices; ts?: number } = {}; const fetchLocalMajorPrices = async (): Promise => { + if (priceCache.data && Date.now() - (priceCache.ts ?? 0) < 60_000) { + return priceCache.data; + } try { const response = await fetch(COINGECKO_API_URL); if (!response.ok) { @@ -59,12 +63,15 @@ const fetchLocalMajorPrices = async (): Promise => { [TokenPeg.ETH]: data.ethereum?.usd, }; // Filter out undefined prices - return Object.entries(prices).reduce((acc, [key, value]) => { + const result = Object.entries(prices).reduce((acc, [key, value]) => { if (value !== undefined) { acc[key as keyof LocalMajorPrices] = value; } return acc; }, {} as LocalMajorPrices); + priceCache.data = result; + priceCache.ts = Date.now(); + return result; } catch (err) { console.error('Failed to fetch internal major token prices for subgraph estimation:', err); return {}; // Return empty object on error @@ -168,7 +175,7 @@ const transformSubgraphMarketToMarket = ( const supplyApy = Number(subgraphMarket.rates?.find((r) => r.side === 'LENDER')?.rate ?? 0); const borrowApy = Number(subgraphMarket.rates?.find((r) => r.side === 'BORROWER')?.rate ?? 0); - const warnings = [SUBGRAPH_NO_ORACLE]; + const warnings: MarketWarning[] = [SUBGRAPH_NO_ORACLE]; // get the prices let loanAssetPrice = safeParseFloat(subgraphMarket.borrowedToken?.lastPriceUSD ?? '0'); diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index ba02f8e4..2bc30332 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -26,7 +26,7 @@ const useUserPosition = ( ) => { const queryKey = ['userPosition', user, chainId, marketKey]; - const { markets } = useMarkets() + const { markets } = useMarkets(); const { data, @@ -64,10 +64,13 @@ const useUserPosition = ( if (market) { // Local market data found, construct position directly - console.log(`Found local market data for ${marketKey}, constructing position from snapshot.`); + console.log( + `Found local market data for ${marketKey}, constructing position from snapshot.`, + ); finalPosition = { market: market, - state: { // Add state from snapshot + state: { + // Add state from snapshot supplyAssets: snapshot.supplyAssets.toString(), supplyShares: snapshot.supplyShares.toString(), borrowAssets: snapshot.borrowAssets.toString(), diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index c3c1ceb0..cdd5733d 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -51,7 +51,14 @@ export const positionKeys = { [...positionKeys.all, 'snapshot', marketKey, userAddress, chainId] as const, // Key for the final enhanced position data, dependent on initialData result enhanced: (user: string | undefined, initialData: InitialDataResponse | undefined) => - ['enhanced-positions', user, initialData] as const, + [ + 'enhanced-positions', + user, + initialData?.finalMarketKeys + .map((k) => `${k.marketUniqueKey.toLowerCase()}-${k.chainId}`) + .sort() + .join(','), + ] as const, }; // --- Helper Fetch Function --- //