diff --git a/src/data-sources/morpho-api/market-whitelist-status.ts b/src/data-sources/morpho-api/market-whitelist-status.ts index 8ffe7db7..f738f785 100644 --- a/src/data-sources/morpho-api/market-whitelist-status.ts +++ b/src/data-sources/morpho-api/market-whitelist-status.ts @@ -7,6 +7,9 @@ import { morphoGraphqlFetcher } from './fetchers'; type MorphoWhitelistMarket = { uniqueKey: string; listed: boolean; + supplyingVaults?: { + address: string | null; + }[]; morphoBlue: { chain: { id: number; @@ -27,7 +30,7 @@ type MorphoWhitelistStatusResponse = { }; type MorphoWhitelistStatusPage = { - items: MorphoWhitelistStatus[]; + items: MorphoMarketMetadata[]; totalCount: number; }; @@ -37,18 +40,48 @@ export type MorphoWhitelistStatus = { listed: boolean; }; +export type MorphoMarketMetadata = MorphoWhitelistStatus & { + supplyingVaults: { + address: string; + }[]; +}; + export type MorphoWhitelistStatusRefresh = { network: SupportedNetworks; statuses: MorphoWhitelistStatus[]; }; -const MORPHO_WHITELIST_PAGE_SIZE = 1_000; +export type MorphoMarketMetadataRefresh = { + network: SupportedNetworks; + metadata: MorphoMarketMetadata[]; +}; + +// The combined `listed + supplyingVaults` markets query costs 1095 complexity per row on Morpho API. +// Keep a small buffer below the 1,000,000 complexity cap to avoid provider-side 500/403 failures. +const MORPHO_WHITELIST_PAGE_SIZE = 900; const MORPHO_WHITELIST_TIMEOUT_MS = 15_000; const MORPHO_WHITELIST_PAGE_BATCH_SIZE = 4; const MORPHO_SUPPORTED_NETWORKS = ALL_SUPPORTED_NETWORKS.filter((network) => supportsMorphoApi(network)); -const fetchMorphoWhitelistStatusPage = async ( +const normalizeSupplyingVaults = (supplyingVaults: MorphoWhitelistMarket['supplyingVaults']): MorphoMarketMetadata['supplyingVaults'] => { + const uniqueVaults = new Set(); + const normalizedVaults: MorphoMarketMetadata['supplyingVaults'] = []; + + supplyingVaults?.forEach((vault) => { + const address = vault.address?.toLowerCase(); + if (!address || uniqueVaults.has(address)) { + return; + } + + uniqueVaults.add(address); + normalizedVaults.push({ address }); + }); + + return normalizedVaults; +}; + +const fetchMorphoMarketMetadataPage = async ( network: SupportedNetworks, skip: number, pageSize: number, @@ -77,18 +110,19 @@ const fetchMorphoWhitelistStatusPage = async ( chainId: market.morphoBlue.chain.id, uniqueKey: market.uniqueKey, listed: market.listed, + supplyingVaults: normalizeSupplyingVaults(market.supplyingVaults), })), totalCount: response.data.markets.pageInfo.countTotal, }; }; -const fetchMorphoWhitelistStatusesForNetwork = async (network: SupportedNetworks): Promise => { - const firstPage = await fetchMorphoWhitelistStatusPage(network, 0, MORPHO_WHITELIST_PAGE_SIZE); +const fetchMorphoMarketMetadataForNetwork = async (network: SupportedNetworks): Promise => { + const firstPage = await fetchMorphoMarketMetadataPage(network, 0, MORPHO_WHITELIST_PAGE_SIZE); if (!firstPage) { throw new Error(`[WhitelistStatus] Failed to fetch first page for network ${network}.`); } - const allStatuses = [...firstPage.items]; + const allMetadata = [...firstPage.items]; const firstPageCount = firstPage.items.length; const totalCount = firstPage.totalCount; @@ -104,7 +138,7 @@ const fetchMorphoWhitelistStatusesForNetwork = async (network: SupportedNetworks for (let index = 0; index < remainingOffsets.length; index += MORPHO_WHITELIST_PAGE_BATCH_SIZE) { const offsetBatch = remainingOffsets.slice(index, index + MORPHO_WHITELIST_PAGE_BATCH_SIZE); const settledPages = await Promise.allSettled( - offsetBatch.map((skip) => fetchMorphoWhitelistStatusPage(network, skip, MORPHO_WHITELIST_PAGE_SIZE)), + offsetBatch.map((skip) => fetchMorphoMarketMetadataPage(network, skip, MORPHO_WHITELIST_PAGE_SIZE)), ); for (const settledPage of settledPages) { @@ -115,27 +149,27 @@ const fetchMorphoWhitelistStatusesForNetwork = async (network: SupportedNetworks throw new Error(`[WhitelistStatus] Failed to fetch one of the paginated whitelist pages for network ${network}.`); } - allStatuses.push(...settledPage.value.items); + allMetadata.push(...settledPage.value.items); } } - if (allStatuses.length < totalCount) { + if (allMetadata.length < totalCount) { throw new Error( - `[WhitelistStatus] Incomplete whitelist dataset for network ${network}: fetched ${allStatuses.length} of ${totalCount}.`, + `[WhitelistStatus] Incomplete whitelist dataset for network ${network}: fetched ${allMetadata.length} of ${totalCount}.`, ); } - return allStatuses; + return allMetadata; }; -export const fetchAllMorphoWhitelistStatuses = async (): Promise => { +export const fetchAllMorphoMarketMetadata = async (): Promise => { const settledResults = await Promise.allSettled( MORPHO_SUPPORTED_NETWORKS.map(async (network) => ({ network, - statuses: await fetchMorphoWhitelistStatusesForNetwork(network), + metadata: await fetchMorphoMarketMetadataForNetwork(network), })), ); - const successfulRefreshes: MorphoWhitelistStatusRefresh[] = []; + const successfulRefreshes: MorphoMarketMetadataRefresh[] = []; for (const settledResult of settledResults) { if (settledResult.status === 'rejected') { @@ -143,14 +177,14 @@ export const fetchAllMorphoWhitelistStatuses = async (): Promise(); - for (const status of settledResult.value.statuses) { - statusByKey.set(getMarketIdentityKey(status.chainId, status.uniqueKey), status); + const metadataByKey = new Map(); + for (const metadata of settledResult.value.metadata) { + metadataByKey.set(getMarketIdentityKey(metadata.chainId, metadata.uniqueKey), metadata); } successfulRefreshes.push({ network: settledResult.value.network, - statuses: Array.from(statusByKey.values()), + metadata: Array.from(metadataByKey.values()), }); } diff --git a/src/graphql/morpho-api-queries.ts b/src/graphql/morpho-api-queries.ts index 40f9c7a2..40a51829 100644 --- a/src/graphql/morpho-api-queries.ts +++ b/src/graphql/morpho-api-queries.ts @@ -159,6 +159,9 @@ export const marketsWhitelistStatusQuery = ` items { uniqueKey listed + supplyingVaults { + address + } morphoBlue { chain { id diff --git a/src/hooks/queries/useMorphoWhitelistStatusQuery.ts b/src/hooks/queries/useMorphoWhitelistStatusQuery.ts index 5c3cf6c6..25233215 100644 --- a/src/hooks/queries/useMorphoWhitelistStatusQuery.ts +++ b/src/hooks/queries/useMorphoWhitelistStatusQuery.ts @@ -1,21 +1,37 @@ import { useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { fetchAllMorphoWhitelistStatuses } from '@/data-sources/morpho-api/market-whitelist-status'; +import { + fetchAllMorphoMarketMetadata, + type MorphoMarketMetadataRefresh, + type MorphoWhitelistStatusRefresh, +} from '@/data-sources/morpho-api/market-whitelist-status'; +import { getMarketIdentityKey } from '@/utils/market-identity'; import { useMarketWhitelistFlags } from '@/stores/useMarketWhitelistFlags'; const EMPTY_LOOKUP = new Map(); +const EMPTY_SUPPLYING_VAULTS_LOOKUP = new Map(); + +const toWhitelistRefreshes = (refreshes: MorphoMarketMetadataRefresh[]): MorphoWhitelistStatusRefresh[] => + refreshes.map(({ network, metadata }) => ({ + network, + statuses: metadata.map(({ chainId, uniqueKey, listed }) => ({ + chainId, + uniqueKey, + listed, + })), + })); export const useMorphoWhitelistStatusQuery = () => { const flagsByNetwork = useMarketWhitelistFlags((state) => state.flagsByNetwork); const replaceNetworks = useMarketWhitelistFlags((state) => state.replaceNetworks); const query = useQuery({ - queryKey: ['morpho-whitelist-status'], + queryKey: ['morpho-market-metadata'], queryFn: async () => { try { - return await fetchAllMorphoWhitelistStatuses(); + return await fetchAllMorphoMarketMetadata(); } catch (error) { - console.warn('Morpho whitelist-status refresh failed; continuing with cached whitelist flags.', error); + console.warn('Morpho market metadata refresh failed; continuing with cached whitelist flags.', error); return []; } }, @@ -29,7 +45,7 @@ export const useMorphoWhitelistStatusQuery = () => { return; } - replaceNetworks(query.data); + replaceNetworks(toWhitelistRefreshes(query.data)); }, [query.data, replaceNetworks]); const whitelistLookup = useMemo(() => { @@ -53,9 +69,30 @@ export const useMorphoWhitelistStatusQuery = () => { return lookup; }, [flagsByNetwork]); + const supplyingVaultsLookup = useMemo(() => { + if (!query.data || query.data.length === 0) { + return EMPTY_SUPPLYING_VAULTS_LOOKUP; + } + + const lookup = new Map(); + + query.data.forEach(({ metadata }) => { + metadata.forEach((market) => { + lookup.set(getMarketIdentityKey(market.chainId, market.uniqueKey), market.supplyingVaults); + }); + }); + + if (lookup.size === 0) { + return EMPTY_SUPPLYING_VAULTS_LOOKUP; + } + + return lookup; + }, [query.data]); + return { whitelistLookup, - isLoading: query.isLoading && whitelistLookup.size === 0, + supplyingVaultsLookup, + isLoading: query.isLoading && whitelistLookup.size === 0 && supplyingVaultsLookup.size === 0, isFetching: query.isFetching, refetch: query.refetch, }; diff --git a/src/hooks/useProcessedMarkets.ts b/src/hooks/useProcessedMarkets.ts index cf900e7a..93edc718 100644 --- a/src/hooks/useProcessedMarkets.ts +++ b/src/hooks/useProcessedMarkets.ts @@ -15,6 +15,26 @@ import type { Market } from '@/utils/types'; const EMPTY_RATE_ENRICHMENTS: MarketRateEnrichmentMap = new Map(); +const hasSameSupplyingVaults = (current: Market['supplyingVaults'], next: Market['supplyingVaults']): boolean => { + const currentVaults = current ?? []; + const nextVaults = next ?? []; + + if (currentVaults.length !== nextVaults.length) { + return false; + } + + for (let index = 0; index < currentVaults.length; index += 1) { + const currentAddress = currentVaults[index]?.address?.toLowerCase(); + const nextAddress = nextVaults[index]?.address?.toLowerCase(); + + if (currentAddress !== nextAddress) { + return false; + } + } + + return true; +}; + const hasPositiveAssets = (value?: string): boolean => { if (!value) return false; try { @@ -65,7 +85,7 @@ const computeUsdValue = (assets: string, decimals: number, price: number): numbe */ export const useProcessedMarkets = () => { const { data: rawMarketsFromQuery, isLoading, isRefetching, error, refetch } = useMarketsQuery(); - const { whitelistLookup } = useMorphoWhitelistStatusQuery(); + const { whitelistLookup, supplyingVaultsLookup } = useMorphoWhitelistStatusQuery(); const { getAllBlacklistedKeys, customBlacklistedMarkets } = useBlacklistedMarkets(); const { showUnwhitelistedMarkets } = useAppSettings(); @@ -82,21 +102,26 @@ export const useProcessedMarkets = () => { }; } - const withMergedWhitelistFlags = rawMarketsFromQuery.map((market) => { - const cachedWhitelisted = whitelistLookup.get(getMarketIdentityKey(market.morphoBlue.chain.id, market.uniqueKey)); + const withMergedMorphoMetadata = rawMarketsFromQuery.map((market) => { + const marketIdentityKey = getMarketIdentityKey(market.morphoBlue.chain.id, market.uniqueKey); + const cachedWhitelisted = whitelistLookup.get(marketIdentityKey); + const cachedSupplyingVaults = supplyingVaultsLookup.get(marketIdentityKey); + const nextWhitelisted = cachedWhitelisted ?? market.whitelisted; + const nextSupplyingVaults = cachedSupplyingVaults ?? market.supplyingVaults; - if (cachedWhitelisted === undefined || cachedWhitelisted === market.whitelisted) { + if (nextWhitelisted === market.whitelisted && hasSameSupplyingVaults(market.supplyingVaults, nextSupplyingVaults)) { return market; } return { ...market, - whitelisted: cachedWhitelisted, + whitelisted: nextWhitelisted, + supplyingVaults: nextSupplyingVaults, }; }); // rawMarketsUnfiltered: before blacklist (for blacklist management modal) - const rawMarketsUnfiltered = withMergedWhitelistFlags; + const rawMarketsUnfiltered = withMergedMorphoMetadata; // Apply blacklist filter const blacklistFiltered = rawMarketsUnfiltered.filter((market) => !allBlacklistedMarketKeys.has(market.uniqueKey)); @@ -122,7 +147,7 @@ export const useProcessedMarkets = () => { allMarkets, whitelistedMarkets, }; - }, [rawMarketsFromQuery, allBlacklistedMarketKeys, whitelistLookup]); + }, [rawMarketsFromQuery, allBlacklistedMarketKeys, whitelistLookup, supplyingVaultsLookup]); const { data: marketRateEnrichments = EMPTY_RATE_ENRICHMENTS,