diff --git a/app/market/[chainId]/[marketid]/RateChart.tsx b/app/market/[chainId]/[marketid]/RateChart.tsx index 632c3036..ea60009c 100644 --- a/app/market/[chainId]/[marketid]/RateChart.tsx +++ b/app/market/[chainId]/[marketid]/RateChart.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-unstable-nested-components */ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { Card, CardHeader, CardBody } from '@nextui-org/card'; import { Progress } from '@nextui-org/progress'; import { @@ -16,31 +16,25 @@ import { import ButtonGroup from '@/components/ButtonGroup'; import { Spinner } from '@/components/common/Spinner'; import { CHART_COLORS } from '@/constants/chartColors'; -import { - TimeseriesDataPoint, - MarketHistoricalData, - Market, - TimeseriesOptions, -} from '@/utils/types'; +import { MarketRates } from '@/utils/types'; +import { TimeseriesDataPoint, Market, TimeseriesOptions } from '@/utils/types'; type RateChartProps = { - historicalData: MarketHistoricalData['rates'] | undefined; + historicalData: MarketRates | undefined; market: Market; isLoading: boolean; - apyTimeframe: '1day' | '7day' | '30day'; - setApyTimeframe: (timeframe: '1day' | '7day' | '30day') => void; - setTimeRangeAndRefetch: (days: number, type: 'rate') => void; - rateTimeRange: TimeseriesOptions; + selectedTimeframe: '1d' | '7d' | '30d'; + selectedTimeRange: TimeseriesOptions; + handleTimeframeChange: (timeframe: '1d' | '7d' | '30d') => void; }; function RateChart({ historicalData, market, isLoading, - apyTimeframe, - setApyTimeframe, - setTimeRangeAndRefetch, - rateTimeRange, + selectedTimeframe, + selectedTimeRange, + handleTimeframeChange, }: RateChartProps) { const [visibleLines, setVisibleLines] = useState({ supplyApy: true, @@ -69,7 +63,9 @@ function RateChart({ const getAverageApyValue = (type: 'supply' | 'borrow') => { if (!historicalData) return 0; const data = type === 'supply' ? historicalData.supplyApy : historicalData.borrowApy; - return data.length > 0 ? data.reduce((sum, point) => sum + point.y, 0) / data.length : 0; + return data.length > 0 + ? data.reduce((sum: number, point: TimeseriesDataPoint) => sum + point.y, 0) / data.length + : 0; }; const getCurrentRateAtUTargetValue = () => { @@ -77,10 +73,12 @@ function RateChart({ }; const getAverageRateAtUTargetValue = () => { - if (!historicalData?.rateAtUTarget) return 0; + if (!historicalData?.rateAtUTarget || historicalData.rateAtUTarget.length === 0) return 0; return ( - historicalData.rateAtUTarget.reduce((sum, point) => sum + point.y, 0) / - historicalData.rateAtUTarget.length + historicalData.rateAtUTarget.reduce( + (sum: number, point: TimeseriesDataPoint) => sum + point.y, + 0, + ) / historicalData.rateAtUTarget.length ); }; @@ -89,44 +87,37 @@ function RateChart({ }; const getAverageUtilizationRate = () => { - if (!historicalData?.utilization) return 0; + if (!historicalData?.utilization || historicalData.utilization.length === 0) return 0; return ( - historicalData.utilization.reduce((sum, point) => sum + point.y, 0) / - historicalData.utilization.length + historicalData.utilization.reduce( + (sum: number, point: TimeseriesDataPoint) => sum + point.y, + 0, + ) / historicalData.utilization.length ); }; const formatTime = (unixTime: number) => { const date = new Date(unixTime * 1000); - if (rateTimeRange.endTimestamp - rateTimeRange.startTimestamp <= 86400) { + if (selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp <= 86400) { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; const timeframeOptions = [ - { key: '1day', label: '1D', value: '1day' }, - { key: '7day', label: '7D', value: '7day' }, - { key: '30day', label: '30D', value: '30day' }, + { key: '1d', label: '1D', value: '1d' }, + { key: '7d', label: '7D', value: '7d' }, + { key: '30d', label: '30D', value: '30d' }, ]; - const handleTimeframeChange = useCallback( - (value: string) => { - setApyTimeframe(value as '1day' | '7day' | '30day'); - const days = value === '1day' ? 1 : value === '7day' ? 7 : 30; - setTimeRangeAndRefetch(days, 'rate'); - }, - [setApyTimeframe, setTimeRangeAndRefetch], - ); - return ( handleTimeframeChange(value as '1d' | '7d' | '30d')} size="sm" variant="default" /> @@ -285,7 +276,7 @@ function RateChart({

Historical Averages{' '} - ({apyTimeframe}) + ({selectedTimeframe})

{isLoading ? (
diff --git a/app/market/[chainId]/[marketid]/VolumeChart.tsx b/app/market/[chainId]/[marketid]/VolumeChart.tsx index c58976de..50a6157b 100644 --- a/app/market/[chainId]/[marketid]/VolumeChart.tsx +++ b/app/market/[chainId]/[marketid]/VolumeChart.tsx @@ -1,6 +1,6 @@ /* eslint-disable react/no-unstable-nested-components */ -import React, { useCallback, useState } from 'react'; +import React, { useState } from 'react'; import { Card, CardHeader, CardBody } from '@nextui-org/card'; import { AreaChart, @@ -17,23 +17,18 @@ import ButtonGroup from '@/components/ButtonGroup'; import { Spinner } from '@/components/common/Spinner'; import { CHART_COLORS } from '@/constants/chartColors'; import { formatReadable } from '@/utils/balance'; -import { - TimeseriesDataPoint, - MarketHistoricalData, - Market, - TimeseriesOptions, -} from '@/utils/types'; +import { MarketVolumes } from '@/utils/types'; +import { TimeseriesDataPoint, Market, TimeseriesOptions } from '@/utils/types'; type VolumeChartProps = { - historicalData: MarketHistoricalData['volumes'] | undefined; + historicalData: MarketVolumes | undefined; market: Market; isLoading: boolean; volumeView: 'USD' | 'Asset'; - volumeTimeframe: '1day' | '7day' | '30day'; - setVolumeTimeframe: (timeframe: '1day' | '7day' | '30day') => void; - setTimeRangeAndRefetch: (days: number, type: 'volume') => void; - volumeTimeRange: TimeseriesOptions; setVolumeView: (view: 'USD' | 'Asset') => void; + selectedTimeframe: '1d' | '7d' | '30d'; + selectedTimeRange: TimeseriesOptions; + handleTimeframeChange: (timeframe: '1d' | '7d' | '30d') => void; }; function VolumeChart({ @@ -41,11 +36,10 @@ function VolumeChart({ market, isLoading, volumeView, - volumeTimeframe, - setVolumeTimeframe, - setTimeRangeAndRefetch, - volumeTimeRange, setVolumeView, + selectedTimeframe, + selectedTimeRange, + handleTimeframeChange, }: VolumeChartProps) { const formatYAxis = (value: number) => { if (volumeView === 'USD') { @@ -57,7 +51,7 @@ function VolumeChart({ const formatTime = (unixTime: number) => { const date = new Date(unixTime * 1000); - if (volumeTimeRange.endTimestamp - volumeTimeRange.startTimestamp <= 24 * 60 * 60) { + if (selectedTimeRange.endTimestamp - selectedTimeRange.startTimestamp <= 24 * 60 * 60) { return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); @@ -77,8 +71,8 @@ function VolumeChart({ return supplyData .map((point: TimeseriesDataPoint, index: number) => { // Get corresponding points from other series - const borrowPoint = borrowData[index]; - const liquidityPoint = liquidityData[index]; + const borrowPoint: TimeseriesDataPoint | undefined = borrowData[index]; + const liquidityPoint: TimeseriesDataPoint | undefined = liquidityData[index]; // Convert values based on view type const supplyValue = @@ -144,7 +138,7 @@ function VolumeChart({ : historicalData?.[`${type}Assets`]; if (!data || data.length === 0) return 0; const sum = data.reduce( - (acc, point) => + (acc: number, point: TimeseriesDataPoint) => acc + Number( volumeView === 'USD' ? point.y : formatUnits(BigInt(point.y), market.loanAsset.decimals), @@ -160,20 +154,11 @@ function VolumeChart({ ]; const timeframeOptions = [ - { key: '1day', label: '1D', value: '1day' }, - { key: '7day', label: '7D', value: '7day' }, - { key: '30day', label: '30D', value: '30day' }, + { key: '1d', label: '1D', value: '1d' }, + { key: '7d', label: '7D', value: '7d' }, + { key: '30d', label: '30D', value: '30d' }, ]; - const handleTimeframeChange = useCallback( - (value: string) => { - setVolumeTimeframe(value as '1day' | '7day' | '30day'); - const days = value === '1day' ? 1 : value === '7day' ? 7 : 30; - setTimeRangeAndRefetch(days, 'volume'); - }, - [setVolumeTimeframe, setTimeRangeAndRefetch], - ); - const [visibleLines, setVisibleLines] = useState({ supply: true, borrow: true, @@ -194,8 +179,8 @@ function VolumeChart({ /> handleTimeframeChange(value as '1d' | '7d' | '30d')} size="sm" variant="default" /> @@ -343,7 +328,7 @@ function VolumeChart({

Historical Averages{' '} - ({volumeTimeframe}) + ({selectedTimeframe})

{isLoading ? (
diff --git a/app/market/[chainId]/[marketid]/content.tsx b/app/market/[chainId]/[marketid]/content.tsx index d04c38f1..69f94499 100644 --- a/app/market/[chainId]/[marketid]/content.tsx +++ b/app/market/[chainId]/[marketid]/content.tsx @@ -16,7 +16,8 @@ import Header from '@/components/layout/header/Header'; import OracleVendorBadge from '@/components/OracleVendorBadge'; import { SupplyModalV2 } from '@/components/SupplyModalV2'; import { TokenIcon } from '@/components/TokenIcon'; -import { useMarket, useMarketHistoricalData } from '@/hooks/useMarket'; +import { useMarketData } from '@/hooks/useMarketData'; +import { useMarketHistoricalData } from '@/hooks/useMarketHistoricalData'; import { useOraclePrice } from '@/hooks/useOraclePrice'; import useUserPositions from '@/hooks/useUserPosition'; import MORPHO_LOGO from '@/imgs/tokens/morpho.svg'; @@ -32,7 +33,30 @@ import RateChart from './RateChart'; import VolumeChart from './VolumeChart'; const NOW = Math.floor(Date.now() / 1000); -const WEEK_IN_SECONDS = 7 * 24 * 60 * 60; +const DAY_IN_SECONDS = 24 * 60 * 60; +const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS; + +// Helper to calculate time range based on timeframe string +const calculateTimeRange = (timeframe: '1d' | '7d' | '30d'): TimeseriesOptions => { + const endTimestamp = NOW; + let startTimestamp; + let interval: TimeseriesOptions['interval'] = 'HOUR'; + switch (timeframe) { + case '1d': + startTimestamp = endTimestamp - DAY_IN_SECONDS; + break; + case '30d': + startTimestamp = endTimestamp - 30 * DAY_IN_SECONDS; + // Use DAY interval for longer ranges if desired, adjust as needed + interval = 'DAY'; + break; + case '7d': + default: + startTimestamp = endTimestamp - WEEK_IN_SECONDS; + break; + } + return { startTimestamp, endTimestamp, interval }; +}; function MarketContent() { // 1. Get URL params and router first @@ -44,35 +68,27 @@ function MarketContent() { const network = Number(chainId as string) as SupportedNetworks; const networkImg = getNetworkImg(network); - // 3. All useState hooks grouped together + // 3. Consolidated state const [showSupplyModal, setShowSupplyModal] = useState(false); const [showBorrowModal, setShowBorrowModal] = useState(false); - const [apyTimeframe, setApyTimeframe] = useState<'1day' | '7day' | '30day'>('7day'); - const [volumeTimeframe, setVolumeTimeframe] = useState<'1day' | '7day' | '30day'>('7day'); + const [selectedTimeframe, setSelectedTimeframe] = useState<'1d' | '7d' | '30d'>('7d'); + const [selectedTimeRange, setSelectedTimeRange] = useState( + calculateTimeRange('7d'), // Initialize based on default timeframe + ); const [volumeView, setVolumeView] = useState<'USD' | 'Asset'>('USD'); - const [rateTimeRange, setRateTimeRange] = useState({ - startTimestamp: NOW - WEEK_IN_SECONDS, - endTimestamp: NOW, - interval: 'HOUR', - }); - const [volumeTimeRange, setVolumeTimeRange] = useState({ - startTimestamp: NOW - WEEK_IN_SECONDS, - endTimestamp: NOW, - interval: 'HOUR', - }); - // 4. Data fetching hooks + // 4. Data fetching hooks - use unified time range const { data: market, isLoading: isMarketLoading, error: marketError, - } = useMarket(marketid as string, network); + } = useMarketData(marketid as string, network); const { data: historicalData, isLoading: isHistoricalLoading, - refetch: refetchHistoricalData, - } = useMarketHistoricalData(marketid as string, network, rateTimeRange, volumeTimeRange); + // No need for manual refetch on time change, queryKey handles it + } = useMarketHistoricalData(marketid as string, network, selectedTimeRange); // Use selectedTimeRange // 5. Oracle price hook - safely handle undefined market const { price: oraclePrice } = useOraclePrice({ @@ -97,26 +113,12 @@ function MarketContent() { return formatUnits(adjusted, 36); }, [oraclePrice, market]); - const setTimeRangeAndRefetch = useCallback( - (days: number, type: 'rate' | 'volume') => { - const endTimestamp = Math.floor(Date.now() / 1000); - const startTimestamp = endTimestamp - days * 24 * 60 * 60; - const newTimeRange = { - startTimestamp, - endTimestamp, - interval: days > 30 ? 'DAY' : 'HOUR', - } as TimeseriesOptions; - - if (type === 'rate') { - setRateTimeRange(newTimeRange); - void refetchHistoricalData.rates(); - } else { - setVolumeTimeRange(newTimeRange); - void refetchHistoricalData.volumes(); - } - }, - [refetchHistoricalData, setRateTimeRange, setVolumeTimeRange], - ); + // Unified handler for timeframe changes + const handleTimeframeChange = useCallback((timeframe: '1d' | '7d' | '30d') => { + setSelectedTimeframe(timeframe); + setSelectedTimeRange(calculateTimeRange(timeframe)); + // No explicit refetch needed, change in selectedTimeRange (part of queryKey) triggers it + }, []); const handleBackToMarkets = useCallback(() => { const currentParams = searchParams.toString(); @@ -139,7 +141,16 @@ function MarketContent() { } if (!market) { - return
Market data not available
; + return ( + <> +
+
+
+ +
+
+ + ); } // 8. Derived values that depend on market data @@ -339,12 +350,11 @@ function MarketContent() { @@ -352,11 +362,10 @@ function MarketContent() {

Activities

diff --git a/src/config/dataSources.ts b/src/config/dataSources.ts new file mode 100644 index 00000000..35064c1e --- /dev/null +++ b/src/config/dataSources.ts @@ -0,0 +1,25 @@ +import { SupportedNetworks } from '@/utils/networks'; + +/** + * Determines the primary data source for market details based on the network. + */ +export const getMarketDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { + switch (network) { + // case SupportedNetworks.Mainnet: + // case SupportedNetworks.Base: + // return 'subgraph'; + default: + return 'morpho'; // Default to Morpho API + } +}; + +/** + * Determines the data source for historical market data. + * Assumes only Morpho API provides this, unless explicitly excluded. + */ +export const getHistoricalDataSource = (network: SupportedNetworks): 'morpho' | 'subgraph' => { + switch (network) { + default: + return 'morpho'; + } +}; diff --git a/src/contexts/MarketsContext.tsx b/src/contexts/MarketsContext.tsx index fcc2ba30..ca90ee33 100644 --- a/src/contexts/MarketsContext.tsx +++ b/src/contexts/MarketsContext.tsx @@ -9,7 +9,7 @@ import { useState, useMemo, } from 'react'; -import { marketsQuery } from '@/graphql/queries'; +import { marketsQuery } from '@/graphql/morpho-api-queries'; import useLiquidations from '@/hooks/useLiquidations'; import { isSupportedChain } from '@/utils/networks'; import { Market } from '@/utils/types'; @@ -46,7 +46,7 @@ export function MarketsProvider({ children }: MarketsProviderProps) { const { loading: liquidationsLoading, - liquidatedMarketIds, + liquidatedMarketKeys, error: liquidationsError, refetch: refetchLiquidations, } = useLiquidations(); @@ -81,7 +81,7 @@ export function MarketsProvider({ children }: MarketsProviderProps) { const processedMarkets = filtered.map((market) => { const warningsWithDetail = getMarketWarningsWithDetail(market); - const isProtectedByLiquidationBots = liquidatedMarketIds.has(market.id); + const isProtectedByLiquidationBots = liquidatedMarketKeys.has(market.uniqueKey); return { ...market, @@ -101,7 +101,7 @@ export function MarketsProvider({ children }: MarketsProviderProps) { } } }, - [liquidatedMarketIds], + [liquidatedMarketKeys], ); useEffect(() => { diff --git a/src/data-sources/morpho-api/fetchers.ts b/src/data-sources/morpho-api/fetchers.ts new file mode 100644 index 00000000..09be399a --- /dev/null +++ b/src/data-sources/morpho-api/fetchers.ts @@ -0,0 +1,32 @@ +import { URLS } from '@/utils/urls'; + +// Generic fetcher for Morpho API +export const morphoGraphqlFetcher = async >( + query: string, + variables: Record, +): Promise => { + const response = await fetch(URLS.MORPHO_BLUE_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error('Network response was not ok from Morpho API'); + } + + const result = (await response.json()) as T; + + // Check for GraphQL errors + if ( + 'errors' in result && + Array.isArray((result as any).errors) && + (result as any).errors.length > 0 + ) { + // Log the full error for debugging + console.error('Morpho API GraphQL Error:', result.errors); + throw new Error('Unknown GraphQL error from Morpho API'); + } + + return result; +}; diff --git a/src/data-sources/morpho-api/historical.ts b/src/data-sources/morpho-api/historical.ts new file mode 100644 index 00000000..ec37ee3e --- /dev/null +++ b/src/data-sources/morpho-api/historical.ts @@ -0,0 +1,107 @@ +import { marketHistoricalDataQuery } from '@/graphql/morpho-api-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { + TimeseriesOptions, + Market, + TimeseriesDataPoint, + MarketRates, + MarketVolumes, +} from '@/utils/types'; +import { morphoGraphqlFetcher } from './fetchers'; + +// --- Types related to Historical Data --- +// Re-exported from types.ts for clarity or define locally if not exported +export type { TimeseriesDataPoint, TimeseriesOptions }; + +// Adjust the response structure type: historicalState contains rates/volumes directly +type MarketWithHistoricalState = Market & { + historicalState: (Partial & Partial) | null; +}; + +type HistoricalDataGraphQLResponse = { + data: { + marketByUniqueKey: MarketWithHistoricalState; + }; + errors?: { message: string }[]; +}; + +// Standardized result type for historical data hooks +// Represents the successful return structure, undefined indicates not found/error +export type HistoricalDataSuccessResult = { + rates: MarketRates; + volumes: MarketVolumes; +}; +// --- End Types --- + +// Fetcher for historical market data from Morpho API +export const fetchMorphoMarketHistoricalData = async ( + uniqueKey: string, + network: SupportedNetworks, + options: TimeseriesOptions, +): Promise => { + try { + const response = await morphoGraphqlFetcher( + marketHistoricalDataQuery, + { + uniqueKey, + options, + chainId: network, + }, + ); + + 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 ( + !historicalState || + Object.keys(historicalState).length === 0 || + !historicalState.supplyApy + ) { + // Example check + console.warn( + 'Historical state not found, empty, or missing essential data in Morpho API response for', + uniqueKey, + ); + return null; + } + + // Construct the expected nested structure for the hook + // Assume API returns *some* data if historicalState is valid + const rates: MarketRates = { + supplyApy: historicalState.supplyApy ?? [], + borrowApy: historicalState.borrowApy ?? [], + rateAtUTarget: historicalState.rateAtUTarget ?? [], + utilization: historicalState.utilization ?? [], + }; + const volumes: MarketVolumes = { + supplyAssetsUsd: historicalState.supplyAssetsUsd ?? [], + borrowAssetsUsd: historicalState.borrowAssetsUsd ?? [], + liquidityAssetsUsd: historicalState.liquidityAssetsUsd ?? [], + supplyAssets: historicalState.supplyAssets ?? [], + borrowAssets: historicalState.borrowAssets ?? [], + liquidityAssets: historicalState.liquidityAssets ?? [], + }; + + // Sort each timeseries array by timestamp (x-axis) ascending + const sortByTimestamp = (a: TimeseriesDataPoint, b: TimeseriesDataPoint) => a.x - b.x; + Object.values(rates).forEach((arr) => arr.sort(sortByTimestamp)); + Object.values(volumes).forEach((arr) => arr.sort(sortByTimestamp)); + + return { rates, volumes }; + } catch (error) { + console.error('Error fetching Morpho historical data:', error); + return null; // Return null on error + } +}; diff --git a/src/data-sources/morpho-api/market.ts b/src/data-sources/morpho-api/market.ts new file mode 100644 index 00000000..c28e2ef9 --- /dev/null +++ b/src/data-sources/morpho-api/market.ts @@ -0,0 +1,36 @@ +import { marketDetailQuery } from '@/graphql/morpho-api-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; +import { getMarketWarningsWithDetail } from '@/utils/warnings'; +import { morphoGraphqlFetcher } from './fetchers'; + +type MarketGraphQLResponse = { + data: { + marketByUniqueKey: Market; + }; + errors?: { message: string }[]; +}; + +const processMarketData = (market: Market): Market => { + const warningsWithDetail = getMarketWarningsWithDetail(market); + return { + ...market, + warningsWithDetail, + isProtectedByLiquidationBots: false, + }; +}; + +// Fetcher for market details from Morpho API +export const fetchMorphoMarket = async ( + uniqueKey: string, + network: SupportedNetworks, +): Promise => { + const response = await morphoGraphqlFetcher(marketDetailQuery, { + uniqueKey, + chainId: network, + }); + if (!response.data || !response.data.marketByUniqueKey) { + throw new Error('Market data not found in Morpho API response'); + } + return processMarketData(response.data.marketByUniqueKey); +}; diff --git a/src/data-sources/subgraph/fetchers.ts b/src/data-sources/subgraph/fetchers.ts new file mode 100644 index 00000000..c23f00b3 --- /dev/null +++ b/src/data-sources/subgraph/fetchers.ts @@ -0,0 +1,31 @@ +export const subgraphGraphqlFetcher = async ( + apiUrl: string, // Subgraph URL can vary + query: string, + variables: Record, +): Promise => { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + console.error('Subgraph network response was not ok', response.status, response.statusText); + throw new Error(`Network response was not ok from Subgraph API: ${apiUrl}`); + } + + const result = (await response.json()) as T; + + // Check for GraphQL errors + if ( + 'errors' in result && + Array.isArray((result as any).errors) && + (result as any).errors.length > 0 + ) { + // Log the full error for debugging + console.error('Subgraph API GraphQL Error:', result.errors); + throw new Error('GraphQL error from Subgraph API'); + } + + return result; +}; diff --git a/src/data-sources/subgraph/historical.ts b/src/data-sources/subgraph/historical.ts new file mode 100644 index 00000000..6b8d5053 --- /dev/null +++ b/src/data-sources/subgraph/historical.ts @@ -0,0 +1,151 @@ +import { marketHourlySnapshotsQuery } from '@/graphql/morpho-subgraph-queries'; +import { SupportedNetworks } from '@/utils/networks'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { TimeseriesOptions, TimeseriesDataPoint, MarketRates, MarketVolumes } from '@/utils/types'; +import { HistoricalDataSuccessResult } from '../morpho-api/historical'; +import { subgraphGraphqlFetcher } from './fetchers'; + +// --- Subgraph Specific Types (Copied from useSubgraphMarketHistoricalData.ts) --- +type SubgraphInterestRate = { + id: string; + rate: string; + side: 'LENDER' | 'BORROWER'; + type: 'VARIABLE' | 'STABLE' | 'FIXED'; +}; + +type SubgraphMarketHourlySnapshot = { + id: string; + timestamp: string; + market: { + id: string; + }; + rates: SubgraphInterestRate[]; + totalDepositBalanceUSD: string; + totalBorrowBalanceUSD: string; + inputTokenBalance: string; + inputTokenPriceUSD: string; + hourlyDepositUSD: string; + hourlyBorrowUSD: string; + outputTokenSupply: string | null; + variableBorrowedTokenBalance: string | null; +}; + +type SubgraphMarketHourlySnapshotQueryResponse = { + data: { + marketHourlySnapshots: SubgraphMarketHourlySnapshot[]; + }; +}; +// --- End Subgraph Specific Types --- + +// Transformation function (simplified) +const transformSubgraphSnapshotsToHistoricalResult = ( + snapshots: SubgraphMarketHourlySnapshot[], // Expect non-empty array here +): HistoricalDataSuccessResult => { + const rates: MarketRates = { + supplyApy: [] as TimeseriesDataPoint[], + borrowApy: [] as TimeseriesDataPoint[], + rateAtUTarget: [] as TimeseriesDataPoint[], + utilization: [] as TimeseriesDataPoint[], + }; + const volumes: MarketVolumes = { + supplyAssetsUsd: [] as TimeseriesDataPoint[], + borrowAssetsUsd: [] as TimeseriesDataPoint[], + liquidityAssetsUsd: [] as TimeseriesDataPoint[], + supplyAssets: [] as TimeseriesDataPoint[], + borrowAssets: [] as TimeseriesDataPoint[], + liquidityAssets: [] as TimeseriesDataPoint[], + }; + + // No need to check for !snapshots here, handled by caller + snapshots.forEach((snapshot) => { + const timestamp = parseInt(snapshot.timestamp, 10); + if (isNaN(timestamp)) { + console.warn('Skipping snapshot due to invalid timestamp:', snapshot); + return; + } + + const snapshotRates = Array.isArray(snapshot.rates) ? snapshot.rates : []; + const supplyRate = snapshotRates.find((r) => r?.side === 'LENDER'); + const borrowRate = snapshotRates.find((r) => r?.side === 'BORROWER'); + + const supplyApyValue = supplyRate?.rate ? parseFloat(supplyRate.rate) : 0; + const borrowApyValue = borrowRate?.rate ? parseFloat(borrowRate.rate) : 0; + + rates.supplyApy.push({ x: timestamp, y: !isNaN(supplyApyValue) ? supplyApyValue : 0 }); + rates.borrowApy.push({ x: timestamp, y: !isNaN(borrowApyValue) ? borrowApyValue : 0 }); + rates.rateAtUTarget.push({ x: timestamp, y: 0 }); + rates.utilization.push({ x: timestamp, y: 0 }); + + const supplyNative = BigInt(snapshot.inputTokenBalance ?? '0'); + const borrowNative = BigInt(snapshot.variableBorrowedTokenBalance ?? '0'); + const liquidityNative = supplyNative - borrowNative; + + volumes.supplyAssetsUsd.push({ x: timestamp, y: 0 }); + volumes.borrowAssetsUsd.push({ x: timestamp, y: 0 }); + volumes.liquidityAssetsUsd.push({ x: timestamp, y: 0 }); + + volumes.supplyAssets.push({ x: timestamp, y: Number(supplyNative) }); + volumes.borrowAssets.push({ x: timestamp, y: Number(borrowNative) }); + volumes.liquidityAssets.push({ x: timestamp, y: Number(liquidityNative) }); + }); + + // Sort data by timestamp + Object.values(rates).forEach((arr: TimeseriesDataPoint[]) => + arr.sort((a: TimeseriesDataPoint, b: TimeseriesDataPoint) => a.x - b.x), + ); + Object.values(volumes).forEach((arr: TimeseriesDataPoint[]) => + arr.sort((a: TimeseriesDataPoint, b: TimeseriesDataPoint) => a.x - b.x), + ); + + return { rates, volumes }; +}; + +// Fetcher function for Subgraph historical data +export const fetchSubgraphMarketHistoricalData = async ( + marketId: string, + network: SupportedNetworks, + timeRange: TimeseriesOptions, +): Promise => { + // Updated return type + + if (!timeRange.startTimestamp || !timeRange.endTimestamp) { + console.warn('Subgraph historical fetch requires start and end timestamps.'); + return null; // Return null + } + + const subgraphApiUrl = getSubgraphUrl(network); + if (!subgraphApiUrl) { + console.error(`Subgraph URL for network ${network} is not defined.`); + return null; // Return null + } + + try { + const variables = { + marketId: marketId.toLowerCase(), + startTimestamp: String(timeRange.startTimestamp), + endTimestamp: String(timeRange.endTimestamp), + }; + + const response = await subgraphGraphqlFetcher( + subgraphApiUrl, + marketHourlySnapshotsQuery, + variables, + ); + + // If no data or empty snapshots array, return null + if ( + !response.data || + !response.data.marketHourlySnapshots || + response.data.marketHourlySnapshots.length === 0 + ) { + console.warn(`No subgraph historical snapshots found for market ${marketId}`); + return null; + } + + // Pass the guaranteed non-empty array to the transformer + return transformSubgraphSnapshotsToHistoricalResult(response.data.marketHourlySnapshots); + } catch (error) { + console.error('Error fetching or processing subgraph historical data:', error); + return null; // Return null on error + } +}; diff --git a/src/data-sources/subgraph/market.ts b/src/data-sources/subgraph/market.ts new file mode 100644 index 00000000..30c86c15 --- /dev/null +++ b/src/data-sources/subgraph/market.ts @@ -0,0 +1,156 @@ +import { Address } from 'viem'; +import { marketQuery as subgraphMarketQuery } from '@/graphql/morpho-subgraph-queries'; // Assuming query is here +import { SupportedNetworks } from '@/utils/networks'; +import { SubgraphMarket, SubgraphMarketQueryResponse, SubgraphToken } from '@/utils/subgraph-types'; +import { getSubgraphUrl } from '@/utils/subgraph-urls'; +import { WarningWithDetail, MorphoChainlinkOracleData, Market } from '@/utils/types'; +import { subgraphGraphqlFetcher } from './fetchers'; + +// Helper to safely parse BigDecimal/BigInt strings +const safeParseFloat = (value: string | null | undefined): number => { + if (value === null || value === undefined) return 0; + try { + return parseFloat(value); + } catch { + return 0; + } +}; + +const safeParseInt = (value: string | null | undefined): number => { + if (value === null || value === undefined) return 0; + try { + return parseInt(value, 10); + } catch { + return 0; + } +}; + +const transformSubgraphMarketToMarket = ( + subgraphMarket: Partial, + network: SupportedNetworks, +): Market => { + const marketId = subgraphMarket.id ?? ''; + 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'; + const fee = subgraphMarket.fee ?? '0'; + + const mapToken = (token: Partial | undefined) => ({ + id: token?.id ?? '0x', + address: token?.id ?? '0x', + symbol: token?.symbol ?? 'Unknown', + name: token?.name ?? 'Unknown Token', + decimals: token?.decimals ?? 18, + }); + + const loanAsset = mapToken(subgraphMarket.borrowedToken); + const collateralAsset = mapToken(subgraphMarket.inputToken); + + const defaultOracleData: MorphoChainlinkOracleData = { + baseFeedOne: null, + baseFeedTwo: null, + quoteFeedOne: null, + quoteFeedTwo: null, + }; + + const chainId = network; + + const borrowAssets = subgraphMarket.totalBorrow ?? '0'; + const supplyAssets = subgraphMarket.totalSupply ?? '0'; + const collateralAssets = subgraphMarket.inputTokenBalance ?? '0'; + const collateralAssetsUsd = safeParseFloat(subgraphMarket.totalValueLockedUSD); + const timestamp = safeParseInt(subgraphMarket.lastUpdate); + + const totalSupplyNum = safeParseFloat(supplyAssets); + const totalBorrowNum = safeParseFloat(borrowAssets); + const utilization = totalSupplyNum > 0 ? (totalBorrowNum / totalSupplyNum) * 100 : 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); + + const liquidityAssets = (BigInt(supplyAssets) - BigInt(borrowAssets)).toString(); + const liquidityAssetsUsd = + safeParseFloat(totalDepositBalanceUSD) - safeParseFloat(totalBorrowBalanceUSD); + + const warningsWithDetail: WarningWithDetail[] = []; // Subgraph doesn't provide warnings directly + + const marketDetail: Market = { + id: marketId, + uniqueKey: marketId, + lltv: lltv, + irmAddress: irmAddress as Address, + collateralPrice: inputTokenPriceUSD, + loanAsset: loanAsset, + collateralAsset: collateralAsset, + state: { + borrowAssets: borrowAssets, + supplyAssets: supplyAssets, + borrowAssetsUsd: totalBorrowBalanceUSD, + supplyAssetsUsd: totalDepositBalanceUSD, + borrowShares: totalBorrowShares, + supplyShares: totalSupplyShares, + liquidityAssets: liquidityAssets, + liquidityAssetsUsd: liquidityAssetsUsd, + collateralAssets: collateralAssets, + collateralAssetsUsd: collateralAssetsUsd, + utilization: utilization, + supplyApy: supplyApy, + borrowApy: borrowApy, + fee: safeParseFloat(fee) / 10000, // Subgraph fee is likely basis points (needs verification) + timestamp: timestamp, + rateAtUTarget: 0, // Not available from subgraph + }, + oracleAddress: subgraphMarket.oracle?.oracleAddress ?? '0x', + morphoBlue: { + id: subgraphMarket.protocol?.id ?? '0x', + address: subgraphMarket.protocol?.id ?? '0x', + chain: { + id: chainId, + }, + }, + warnings: [], // Subgraph doesn't provide warnings + warningsWithDetail: warningsWithDetail, + oracle: { + data: defaultOracleData, // Placeholder oracle data + }, + isProtectedByLiquidationBots: false, // Not available from subgraph + badDebt: undefined, // Not available from subgraph + realizedBadDebt: undefined, // Not available from subgraph + }; + + return marketDetail; +}; + +// Fetcher for market details from Subgraph +export const fetchSubgraphMarket = async ( + uniqueKey: string, + 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 response = await subgraphGraphqlFetcher( + subgraphApiUrl, + subgraphMarketQuery, + { + id: uniqueKey.toLowerCase(), // Ensure ID is lowercase for subgraph + }, + ); + + const marketData = response.data.market; + + if (!marketData) { + console.warn(`Market with key ${uniqueKey} not found in Subgraph response.`); + return null; // Return null if not found, hook can handle this + } + + return transformSubgraphMarketToMarket(marketData, network); +}; diff --git a/src/graphql/statsQueries.ts b/src/graphql/monarch-stats-queries.ts similarity index 100% rename from src/graphql/statsQueries.ts rename to src/graphql/monarch-stats-queries.ts diff --git a/src/graphql/queries.ts b/src/graphql/morpho-api-queries.ts similarity index 93% rename from src/graphql/queries.ts rename to src/graphql/morpho-api-queries.ts index 6e533dce..b926e702 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/morpho-api-queries.ts @@ -1,3 +1,6 @@ +// Queries for Morpho Officail API +// Reference: https://blue-api.morpho.org/graphql + export const feedFieldsFragment = ` fragment FeedFields on OracleFeed { address @@ -13,7 +16,6 @@ export const feedFieldsFragment = ` export const marketFragment = ` fragment MarketFields on Market { - id lltv uniqueKey irmAddress @@ -27,16 +29,12 @@ export const marketFragment = ` id } } - oracleInfo { - type - } loanAsset { id address symbol name decimals - priceUsd } collateralAsset { id @@ -44,7 +42,6 @@ export const marketFragment = ` symbol name decimals - priceUsd } state { borrowAssets @@ -63,26 +60,6 @@ export const marketFragment = ` fee timestamp rateAtUTarget - rewards { - yearlySupplyTokens - asset { - address - priceUsd - spotPriceEth - } - amountPerSuppliedToken - amountPerBorrowedToken - } - monthlySupplyApy - monthlyBorrowApy - dailySupplyApy - dailyBorrowApy - weeklySupplyApy - weeklyBorrowApy - } - dailyApys { - netSupplyApy - netBorrowApy } warnings { type diff --git a/src/graphql/morpho-subgraph-queries.ts b/src/graphql/morpho-subgraph-queries.ts new file mode 100644 index 00000000..98d0d332 --- /dev/null +++ b/src/graphql/morpho-subgraph-queries.ts @@ -0,0 +1,147 @@ +export const tokenFragment = ` + fragment TokenFields on Token { + id + name + symbol + decimals + lastPriceUSD + } +`; + +export const oracleFragment = ` + fragment OracleFields on Oracle { + id + oracleAddress + oracleSource + isActive + isUSD + } +`; + +export const marketFragment = ` + fragment SubgraphMarketFields on Market { + id + lltv + irm + inputToken { # collateralAsset + ...TokenFields + } + inputTokenPriceUSD + borrowedToken { # loanAsset + ...TokenFields + } + totalDepositBalanceUSD + totalBorrowBalanceUSD + totalSupplyShares + totalBorrowShares + totalSupply + totalBorrow + fee + + name + isActive + canBorrowFrom + canUseAsCollateral + maximumLTV + liquidationThreshold + liquidationPenalty + createdTimestamp + createdBlockNumber + inputTokenBalance + variableBorrowedTokenBalance + totalValueLockedUSD + lastUpdate + reserves + reserveFactor + oracle { + ...OracleFields + } + rates { + id + rate # APY + side + type + } + protocol { + id + network # Chain Name + protocol # Protocol Name + } + } + ${tokenFragment} + ${oracleFragment} +`; + +export const marketsQuery = ` + query getSubgraphMarkets($first: Int, $where: Market_filter) { + markets(first: $first, where: $where, orderBy: totalValueLockedUSD, orderDirection: desc) { + ...SubgraphMarketFields + } + } + ${marketFragment} +`; + +// Add other queries as needed, e.g., for user positions based on subgraph schema + +export const marketQuery = ` + query getSubgraphMarket($id: Bytes!) { + market(id: $id) { + ...SubgraphMarketFields + } + } + ${marketFragment} +`; + +// --- Added for Historical Data --- + +export const marketHourlySnapshotFragment = ` + fragment MarketHourlySnapshotFields on MarketHourlySnapshot { + id + timestamp + market { + id + inputToken { + ...TokenFields + } + borrowedToken { + ...TokenFields + } + } + rates { + id + rate # APY + side + type + } + totalDepositBalanceUSD + totalBorrowBalanceUSD + inputTokenBalance + inputTokenPriceUSD + hourlyDepositUSD + hourlyBorrowUSD + outputTokenSupply + variableBorrowedTokenBalance + # Note: The subgraph schema for snapshots doesn't seem to directly expose + # total native supply/borrow amounts historically, only USD values and hourly deltas. + } +`; + +export const marketHourlySnapshotsQuery = ` + query getMarketHourlySnapshots($marketId: Bytes!, $startTimestamp: BigInt!, $endTimestamp: BigInt!) { + marketHourlySnapshots( + first: 1000, # Subgraph max limit + orderBy: timestamp, + orderDirection: asc, + where: { + market: $marketId, + timestamp_gte: $startTimestamp, + timestamp_lte: $endTimestamp + } + ) { + ...MarketHourlySnapshotFields + } + } + ${marketHourlySnapshotFragment} + ${tokenFragment} # Ensure TokenFields fragment is included +`; +// --- End Added Section --- diff --git a/src/hooks/useLiquidations.ts b/src/hooks/useLiquidations.ts index 77bb01fe..bd758048 100644 --- a/src/hooks/useLiquidations.ts +++ b/src/hooks/useLiquidations.ts @@ -70,7 +70,7 @@ type QueryResult = { const useLiquidations = () => { const [loading, setLoading] = useState(true); const [isRefetching, setIsRefetching] = useState(false); - const [liquidatedMarketIds, setLiquidatedMarketIds] = useState>(new Set()); + const [liquidatedMarketKeys, setLiquidatedMarketKeys] = useState>(new Set()); const [error, setError] = useState(null); const fetchLiquidations = useCallback(async (isRefetch = false) => { @@ -80,7 +80,7 @@ const useLiquidations = () => { } else { setLoading(true); } - const liquidatedIds = new Set(); + const liquidatedKeys = new Set(); let skip = 0; const pageSize = 1000; let totalCount = 0; @@ -100,7 +100,7 @@ const useLiquidations = () => { liquidations.forEach((tx) => { if (tx.data && 'market' in tx.data) { - liquidatedIds.add(tx.data.market.id); + liquidatedKeys.add(tx.data.market.uniqueKey); } }); @@ -108,7 +108,7 @@ const useLiquidations = () => { skip += pageInfo.count; } while (skip < totalCount); - setLiquidatedMarketIds(liquidatedIds); + setLiquidatedMarketKeys(liquidatedKeys); } catch (_error) { setError(_error); } finally { @@ -125,7 +125,7 @@ const useLiquidations = () => { fetchLiquidations(true).catch(console.error); }, [fetchLiquidations]); - return { loading, isRefetching, liquidatedMarketIds, error, refetch }; + return { loading, isRefetching, liquidatedMarketKeys, error, refetch }; }; export default useLiquidations; diff --git a/src/hooks/useMarket.ts b/src/hooks/useMarket.ts deleted file mode 100644 index 1c4d6a8e..00000000 --- a/src/hooks/useMarket.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { SupportedNetworks } from '@/utils/networks'; -import { URLS } from '@/utils/urls'; -import { getMarketWarningsWithDetail } from '@/utils/warnings'; -import { marketDetailQuery, marketHistoricalDataQuery } from '../graphql/queries'; -import { MarketDetail, TimeseriesOptions, Market } from '../utils/types'; - -type GraphQLResponse = { - data: { - marketByUniqueKey: MarketDetail; - }; - errors?: { message: string }[]; -}; - -const graphqlFetcher = async ( - query: string, - variables: Record, -): Promise => { - const response = await fetch(URLS.MORPHO_BLUE_API, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query, variables }), - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - - const result = (await response.json()) as GraphQLResponse; - - if (result.errors) { - throw new Error(result.errors[0].message); - } - - return result; -}; - -const processMarketData = (market: Market): MarketDetail => { - const warningsWithDetail = getMarketWarningsWithDetail(market); - - return { - ...market, - warningsWithDetail, - isProtectedByLiquidationBots: false, // NOT needed for now, might implement later - historicalState: { - supplyApy: [], - borrowApy: [], - supplyAssetsUsd: [], - borrowAssetsUsd: [], - rateAtUTarget: [], - utilization: [], - supplyAssets: [], - borrowAssets: [], - liquidityAssetsUsd: [], - liquidityAssets: [], - }, - }; -}; - -export const useMarket = (uniqueKey: string, network: SupportedNetworks) => { - return useQuery({ - queryKey: ['market', uniqueKey, network], - queryFn: async () => { - const response = await graphqlFetcher(marketDetailQuery, { uniqueKey, chainId: network }); - return processMarketData(response.data.marketByUniqueKey); - }, - }); -}; - -export const useMarketHistoricalData = ( - uniqueKey: string, - network: SupportedNetworks, - rateOptions: TimeseriesOptions, - volumeOptions: TimeseriesOptions, -) => { - const fetchHistoricalData = async (options: TimeseriesOptions) => { - const response = await graphqlFetcher(marketHistoricalDataQuery, { - uniqueKey, - options, - chainId: network, - }); - return response.data.marketByUniqueKey.historicalState; - }; - - const rateQuery = useQuery({ - queryKey: ['marketHistoricalRates', uniqueKey, network, rateOptions], - queryFn: async () => fetchHistoricalData(rateOptions), - }); - - const volumeQuery = useQuery({ - queryKey: ['marketHistoricalVolumes', uniqueKey, network, volumeOptions], - queryFn: async () => fetchHistoricalData(volumeOptions), - }); - - return { - data: { - rates: rateQuery.data, - volumes: volumeQuery.data, - }, - isLoading: { - rates: rateQuery.isLoading, - volumes: volumeQuery.isLoading, - }, - error: { - rates: rateQuery.error, - volumes: volumeQuery.error, - }, - refetch: { - rates: rateQuery.refetch, - volumes: volumeQuery.refetch, - }, - }; -}; diff --git a/src/hooks/useMarketBorrows.ts b/src/hooks/useMarketBorrows.ts index 98e63b00..f45279f0 100644 --- a/src/hooks/useMarketBorrows.ts +++ b/src/hooks/useMarketBorrows.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { marketBorrowsQuery } from '@/graphql/queries'; +import { marketBorrowsQuery } from '@/graphql/morpho-api-queries'; import { URLS } from '@/utils/urls'; export type MarketBorrowTransaction = { diff --git a/src/hooks/useMarketData.ts b/src/hooks/useMarketData.ts new file mode 100644 index 00000000..0668422a --- /dev/null +++ b/src/hooks/useMarketData.ts @@ -0,0 +1,59 @@ +import { useQuery } from '@tanstack/react-query'; +import { getMarketDataSource } from '@/config/dataSources'; +import { fetchMorphoMarket } from '@/data-sources/morpho-api/market'; +import { fetchSubgraphMarket } from '@/data-sources/subgraph/market'; +import { SupportedNetworks } from '@/utils/networks'; +import { Market } from '@/utils/types'; + +export const useMarketData = ( + uniqueKey: string | undefined, + network: SupportedNetworks | undefined, +) => { + 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 + } + + 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 + } + + // 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 + placeholderData: (previousData) => previousData ?? null, + retry: 1, // Optional: retry once on failure + }); + + return { + data: data, + isLoading: isLoading, + error: error, + refetch: refetch, + dataSource: dataSource, // Expose the determined data source + }; +}; diff --git a/src/hooks/useMarketHistoricalData.ts b/src/hooks/useMarketHistoricalData.ts new file mode 100644 index 00000000..ad33446a --- /dev/null +++ b/src/hooks/useMarketHistoricalData.ts @@ -0,0 +1,68 @@ +import { useQuery } from '@tanstack/react-query'; +import { getHistoricalDataSource } from '@/config/dataSources'; +import { + fetchMorphoMarketHistoricalData, + HistoricalDataSuccessResult, +} from '@/data-sources/morpho-api/historical'; +import { fetchSubgraphMarketHistoricalData } from '@/data-sources/subgraph/historical'; +import { SupportedNetworks } from '@/utils/networks'; +import { TimeseriesOptions } from '@/utils/types'; + +export const useMarketHistoricalData = ( + uniqueKey: string | undefined, + network: SupportedNetworks | undefined, + options: TimeseriesOptions | undefined, +) => { + const queryKey = [ + 'marketHistoricalData', + uniqueKey, + network, + options?.startTimestamp, + options?.endTimestamp, + options?.interval, + ]; + + const dataSource = network ? getHistoricalDataSource(network) : null; + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: queryKey, + queryFn: async (): Promise => { + if (!uniqueKey || !network || !options || !dataSource) { + console.log('Historical data prerequisites not met or source unavailable.', { + uniqueKey, + network, + options, + dataSource, + }); + return null; + } + + 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; + } else if (dataSource === 'subgraph') { + const res = await fetchSubgraphMarketHistoricalData(uniqueKey, network, options); + console.log('res', res); + return res; + } + + console.warn('Unknown historical data source determined'); + return null; + }, + enabled: !!uniqueKey && !!network && !!options && !!dataSource, + staleTime: 1000 * 60 * 5, + placeholderData: null, + retry: 1, + }); + + return { + data: data, + isLoading: isLoading, + error: error, + refetch: refetch, + dataSource: dataSource, + }; +}; diff --git a/src/hooks/useMarketLiquidations.ts b/src/hooks/useMarketLiquidations.ts index 20216174..2fd6cfe6 100644 --- a/src/hooks/useMarketLiquidations.ts +++ b/src/hooks/useMarketLiquidations.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { marketLiquidationsQuery } from '@/graphql/queries'; +import { marketLiquidationsQuery } from '@/graphql/morpho-api-queries'; import { URLS } from '@/utils/urls'; export type MarketLiquidationTransaction = { diff --git a/src/hooks/useMarketSupplies.ts b/src/hooks/useMarketSupplies.ts index 27997e81..6fb21cd3 100644 --- a/src/hooks/useMarketSupplies.ts +++ b/src/hooks/useMarketSupplies.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { marketSuppliesQuery } from '@/graphql/queries'; +import { marketSuppliesQuery } from '@/graphql/morpho-api-queries'; import { URLS } from '@/utils/urls'; export type MarketSupplyTransaction = { diff --git a/src/hooks/useUserPosition.ts b/src/hooks/useUserPosition.ts index 0356a3ff..a92de336 100644 --- a/src/hooks/useUserPosition.ts +++ b/src/hooks/useUserPosition.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Address } from 'viem'; -import { userPositionForMarketQuery } from '@/graphql/queries'; +import { userPositionForMarketQuery } from '@/graphql/morpho-api-queries'; import { SupportedNetworks } from '@/utils/networks'; import { fetchPositionSnapshot } from '@/utils/positions'; import { MarketPosition } from '@/utils/types'; @@ -60,10 +60,7 @@ const useUserPositions = ( if (currentSnapshot) { setPosition({ market: data.data.marketPosition.market, - state: { - ...currentSnapshot, - collateral: data.data.marketPosition.state.collateral, - }, + state: currentSnapshot, }); } else { setPosition(data.data.marketPosition); diff --git a/src/hooks/useUserPositions.ts b/src/hooks/useUserPositions.ts index 18ea2174..64b746b2 100644 --- a/src/hooks/useUserPositions.ts +++ b/src/hooks/useUserPositions.ts @@ -3,7 +3,7 @@ import { useCallback } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Address } from 'viem'; -import { userPositionsQuery } from '@/graphql/queries'; +import { userPositionsQuery } from '@/graphql/morpho-api-queries'; import { SupportedNetworks } from '@/utils/networks'; import { fetchPositionSnapshot, type PositionSnapshot } from '@/utils/positions'; import { MarketPosition, Market } from '@/utils/types'; diff --git a/src/hooks/useUserRebalancerInfo.ts b/src/hooks/useUserRebalancerInfo.ts index c473c21b..2de737ae 100644 --- a/src/hooks/useUserRebalancerInfo.ts +++ b/src/hooks/useUserRebalancerInfo.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { userRebalancerInfoQuery } from '@/graphql/queries'; +import { userRebalancerInfoQuery } from '@/graphql/morpho-api-queries'; import { UserRebalancerInfo } from '@/utils/types'; import { URLS } from '@/utils/urls'; diff --git a/src/hooks/useUserTransactions.ts b/src/hooks/useUserTransactions.ts index 58e7215e..88ee588a 100644 --- a/src/hooks/useUserTransactions.ts +++ b/src/hooks/useUserTransactions.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import { userTransactionsQuery } from '@/graphql/queries'; +import { userTransactionsQuery } from '@/graphql/morpho-api-queries'; import { SupportedNetworks } from '@/utils/networks'; import { UserTransaction } from '@/utils/types'; import { URLS } from '@/utils/urls'; diff --git a/src/services/statsService.ts b/src/services/statsService.ts index 7689a380..ef02bc7a 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -1,5 +1,5 @@ import { request, gql } from 'graphql-request'; -import { transactionsByTimeRangeQuery, userGrowthQuery } from '@/graphql/statsQueries'; +import { transactionsByTimeRangeQuery, userGrowthQuery } from '@/graphql/monarch-stats-queries'; import { SupportedNetworks } from '@/utils/networks'; import { processTransactionData } from '@/utils/statsDataProcessing'; import { diff --git a/src/utils/morpho.ts b/src/utils/morpho.ts index f4376b25..40e80643 100644 --- a/src/utils/morpho.ts +++ b/src/utils/morpho.ts @@ -1,4 +1,3 @@ -import { formatBalance } from './balance'; import { SupportedNetworks } from './networks'; import { UserTxTypes } from './types'; @@ -17,22 +16,6 @@ export const getBundlerV2 = (chain: SupportedNetworks) => { return '0x4095F064B8d3c3548A3bebfd0Bbfd04750E30077'; }; -export const getRewardPer1000USD = (yearlySupplyTokens: string, marketSupplyAssetUSD: number) => { - return ((formatBalance(yearlySupplyTokens, 18) / marketSupplyAssetUSD) * 1000).toString(); -}; - -export const getUserRewardPerYear = ( - yearlySupplyTokens: string | null, - marketSupplyAssetUSD: number, - userSuppliedUSD: number, -) => { - if (!yearlySupplyTokens) return '0'; - return ( - (formatBalance(yearlySupplyTokens, 18) * Number(userSuppliedUSD)) / - marketSupplyAssetUSD - ).toFixed(2); -}; - export const getIRMTitle = (address: string) => { switch (address.toLowerCase()) { case '0x870ac11d48b15db9a138cf899d20f13f79ba00bc': diff --git a/src/utils/subgraph-types.ts b/src/utils/subgraph-types.ts new file mode 100644 index 00000000..99be139c --- /dev/null +++ b/src/utils/subgraph-types.ts @@ -0,0 +1,85 @@ +import { Address } from 'viem'; + +// Corresponds to tokenFragment +export type SubgraphToken = { + id: Address; // address + name: string; + symbol: string; + decimals: number; + lastPriceUSD: string | null; // BigDecimal represented as string +}; + +// Corresponds to oracleFragment +export type SubgraphOracle = { + id: string; + oracleAddress: Address; + oracleSource: string | null; + isActive: boolean; + isUSD: boolean; +}; + +// Corresponds to InterestRate type within marketFragment +export type SubgraphInterestRate = { + id: string; + rate: string; // BigDecimal represented as string (APY percentage) + side: 'LENDER' | 'BORROWER'; + type: 'STABLE' | 'VARIABLE' | 'FIXED'; +}; + +// Corresponds to protocol details within marketFragment +export type SubgraphProtocolInfo = { + id: string; + network: string; // e.g., "MAINNET", "BASE" + protocol: string; // e.g., "Morpho Blue" +}; + +// Corresponds to the main marketFragment (SubgraphMarketFields) +export type SubgraphMarket = { + id: Address; // uniqueKey (market address) + name: string; + 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) + 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) + 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?) + oracle: SubgraphOracle; + rates: SubgraphInterestRate[]; + protocol: SubgraphProtocolInfo; +}; + +// Type for the GraphQL response structure using marketsQuery +export type SubgraphMarketsQueryResponse = { + data: { + markets: SubgraphMarket[]; + }; + errors?: { message: string }[]; +}; + +// 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 + }; + errors?: { message: string }[]; +}; diff --git a/src/utils/subgraph-urls.ts b/src/utils/subgraph-urls.ts new file mode 100644 index 00000000..75fc493b --- /dev/null +++ b/src/utils/subgraph-urls.ts @@ -0,0 +1,29 @@ +import { SupportedNetworks } from './networks'; + +const apiKey = process.env.NEXT_PUBLIC_THEGRAPH_API_KEY; + +// Ensure the API key is available +if (!apiKey) { + console.error('NEXT_PUBLIC_THEGRAPH_API_KEY is not set in environment variables.'); + // Potentially throw an error or handle this case as needed +} + +const baseSubgraphUrl = apiKey + ? `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/71ZTy1veF9twER9CLMnPWeLQ7GZcwKsjmygejrgKirqs` + : undefined; + +// TODO: Replace 'YOUR_MAINNET_SUBGRAPH_ID' with the actual Mainnet Subgraph ID +const mainnetSubgraphUrl = apiKey + ? `https://gateway.thegraph.com/api/${apiKey}/subgraphs/id/8Lz789DP5VKLXumTMTgygjU2xtuzx8AhbaacgN5PYCAs` + : undefined; + +// Map network IDs (from SupportedNetworks) to Subgraph URLs +export const SUBGRAPH_URLS: { [key in SupportedNetworks]?: string } = { + [SupportedNetworks.Base]: baseSubgraphUrl, + [SupportedNetworks.Mainnet]: mainnetSubgraphUrl, + // Add other supported networks and their Subgraph URLs here +}; + +export const getSubgraphUrl = (network: SupportedNetworks): string | undefined => { + return SUBGRAPH_URLS[network]; +}; diff --git a/src/utils/types.ts b/src/utils/types.ts index f5a5017b..3c1b6a58 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -97,7 +97,6 @@ export type TokenInfo = { symbol: string; name: string; decimals: number; - priceUsd: number; }; // Common types @@ -273,9 +272,6 @@ export type Market = { id: number; }; }; - oracleInfo: { - type: string; - }; loanAsset: TokenInfo; collateralAsset: TokenInfo; state: { @@ -295,22 +291,6 @@ export type Market = { fee: number; timestamp: number; rateAtUTarget: number; - rewards: { - yearlySupplyTokens: string; - asset: { - address: string; - priceUsd: string | null; - spotPriceEth: string | null; - }; - amountPerSuppliedToken: string; - amountPerBorrowedToken: string; - }[]; - monthlySupplyApy: number; - monthlyBorrowApy: number; - dailySupplyApy: number; - dailyBorrowApy: number; - weeklySupplyApy: number; - weeklyBorrowApy: number; }; warnings: MarketWarning[]; badDebt?: { @@ -321,14 +301,11 @@ export type Market = { underlying: number; usd: number; }; - dailyApys: { - netSupplyApy: number; - netBorrowApy: number; - }; // appended by us warningsWithDetail: WarningWithDetail[]; isProtectedByLiquidationBots: boolean; + oracle: { data: MorphoChainlinkOracleData; }; @@ -342,17 +319,18 @@ export type TimeseriesDataPoint = { export type TimeseriesOptions = { startTimestamp: number; endTimestamp: number; - interval: 'MINUTE' | 'HALF_HOUR' | 'HOUR' | 'DAY' | 'WEEK' | 'MONTH' | 'QUARTER' | 'YEAR' | 'ALL'; + interval: 'HOUR' | 'DAY' | 'WEEK' | 'MONTH'; }; -type MarketRates = { +// Export MarketRates and MarketVolumes +export type MarketRates = { supplyApy: TimeseriesDataPoint[]; borrowApy: TimeseriesDataPoint[]; rateAtUTarget: TimeseriesDataPoint[]; utilization: TimeseriesDataPoint[]; }; -type MarketVolumes = { +export type MarketVolumes = { supplyAssetsUsd: TimeseriesDataPoint[]; borrowAssetsUsd: TimeseriesDataPoint[]; liquidityAssetsUsd: TimeseriesDataPoint[]; @@ -361,7 +339,7 @@ type MarketVolumes = { liquidityAssets: TimeseriesDataPoint[]; }; -export type MarketDetail = Market & { +export type HistoricalData = { historicalState: MarketRates & MarketVolumes; };