Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/DataPrefetcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import { usePathname } from 'next/navigation';
import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery';
import { useMorphoWhitelistStatusQuery } from '@/hooks/queries/useMorphoWhitelistStatusQuery';
import { useTokensQuery } from '@/hooks/queries/useTokensQuery';
import { useMerklCampaignsQuery } from '@/hooks/queries/useMerklCampaignsQuery';

function DataPrefetcherContent() {
useMorphoWhitelistStatusQuery();
useMarketsQuery();
useTokensQuery();
useMerklCampaignsQuery();
Expand Down
158 changes: 158 additions & 0 deletions src/data-sources/morpho-api/market-whitelist-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { supportsMorphoApi } from '@/config/dataSources';
import { marketsWhitelistStatusQuery } from '@/graphql/morpho-api-queries';
import { getMarketIdentityKey } from '@/utils/market-identity';
import { ALL_SUPPORTED_NETWORKS, type SupportedNetworks } from '@/utils/networks';
import { morphoGraphqlFetcher } from './fetchers';

type MorphoWhitelistMarket = {
uniqueKey: string;
listed: boolean;
morphoBlue: {
chain: {
id: number;
};
};
};

type MorphoWhitelistStatusResponse = {
data?: {
markets?: {
items?: MorphoWhitelistMarket[];
pageInfo?: {
countTotal: number;
};
};
};
errors?: { message: string }[];
};

type MorphoWhitelistStatusPage = {
items: MorphoWhitelistStatus[];
totalCount: number;
};

export type MorphoWhitelistStatus = {
chainId: number;
uniqueKey: string;
listed: boolean;
};

export type MorphoWhitelistStatusRefresh = {
network: SupportedNetworks;
statuses: MorphoWhitelistStatus[];
};

const MORPHO_WHITELIST_PAGE_SIZE = 1_000;
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 (
network: SupportedNetworks,
skip: number,
pageSize: number,
): Promise<MorphoWhitelistStatusPage | null> => {
const response = await morphoGraphqlFetcher<MorphoWhitelistStatusResponse>(
marketsWhitelistStatusQuery,
{
first: pageSize,
skip,
where: {
chainId_in: [network],
},
},
{
timeoutMs: MORPHO_WHITELIST_TIMEOUT_MS,
},
);

if (!response?.data?.markets?.items || !response.data.markets.pageInfo) {
console.warn(`[WhitelistStatus] Skipping failed page at skip=${skip} for network ${network}`);
return null;
}

return {
items: response.data.markets.items.map((market) => ({
chainId: market.morphoBlue.chain.id,
uniqueKey: market.uniqueKey,
listed: market.listed,
})),
totalCount: response.data.markets.pageInfo.countTotal,
};
};

const fetchMorphoWhitelistStatusesForNetwork = async (network: SupportedNetworks): Promise<MorphoWhitelistStatus[]> => {
const firstPage = await fetchMorphoWhitelistStatusPage(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 firstPageCount = firstPage.items.length;
const totalCount = firstPage.totalCount;

if (firstPageCount === 0 && totalCount > 0) {
throw new Error(`[WhitelistStatus] Received empty first page for network ${network} while totalCount=${totalCount}.`);
}

const remainingOffsets: number[] = [];
for (let nextSkip = firstPageCount; nextSkip < totalCount; nextSkip += MORPHO_WHITELIST_PAGE_SIZE) {
remainingOffsets.push(nextSkip);
}
Comment on lines +99 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Offset calculation uses item count instead of page size.

If the first page returns fewer items than MORPHO_WHITELIST_PAGE_SIZE (e.g., 450 instead of 500), the loop starts at 450 and increments by 500. This skips items 450-499.

Use fixed page size for offset calculation:

Proposed fix
   const remainingOffsets: number[] = [];
-  for (let nextSkip = firstPageCount; nextSkip < totalCount; nextSkip += MORPHO_WHITELIST_PAGE_SIZE) {
+  for (let nextSkip = MORPHO_WHITELIST_PAGE_SIZE; nextSkip < totalCount; nextSkip += MORPHO_WHITELIST_PAGE_SIZE) {
     remainingOffsets.push(nextSkip);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/data-sources/morpho-api/market-whitelist-status.ts` around lines 99 -
102, The offset loop currently starts at firstPageCount which is the number of
items returned on the first page and then increments by
MORPHO_WHITELIST_PAGE_SIZE, causing gaps when the first page returns fewer than
a full page. Change the loop to start at MORPHO_WHITELIST_PAGE_SIZE (not
firstPageCount) and increment by MORPHO_WHITELIST_PAGE_SIZE until totalCount to
build remainingOffsets; reference the variables remainingOffsets,
firstPageCount, totalCount, MORPHO_WHITELIST_PAGE_SIZE and the loop variable
nextSkip when making the change.


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)),
);

for (const settledPage of settledPages) {
if (settledPage.status === 'rejected') {
throw settledPage.reason;
}
if (!settledPage.value) {
throw new Error(`[WhitelistStatus] Failed to fetch one of the paginated whitelist pages for network ${network}.`);
}

allStatuses.push(...settledPage.value.items);
}
}

if (allStatuses.length < totalCount) {
throw new Error(
`[WhitelistStatus] Incomplete whitelist dataset for network ${network}: fetched ${allStatuses.length} of ${totalCount}.`,
);
}

return allStatuses;
Comment on lines +122 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate unique market count before returning success.

Line 122 checks the raw row count, but Lines 146-149 later collapse rows by chainId + uniqueKey. If two offset pages overlap, this still passes the length check and then silently drops a row during dedup, leaving an incomplete whitelist snapshot. Dedup inside fetchMorphoWhitelistStatusesForNetwork and compare the unique count to totalCount before returning.

Possible fix
-  if (allStatuses.length < totalCount) {
+  const statusesByKey = new Map<string, MorphoWhitelistStatus>();
+  for (const status of allStatuses) {
+    statusesByKey.set(getMarketIdentityKey(status.chainId, status.uniqueKey), status);
+  }
+
+  if (statusesByKey.size !== totalCount) {
     throw new Error(
-      `[WhitelistStatus] Incomplete whitelist dataset for network ${network}: fetched ${allStatuses.length} of ${totalCount}.`,
+      `[WhitelistStatus] Incomplete whitelist dataset for network ${network}: fetched ${statusesByKey.size} of ${totalCount} unique markets.`,
     );
   }
 
-  return allStatuses;
+  return Array.from(statusesByKey.values());

Then fetchAllMorphoWhitelistStatuses can keep settledResult.value.statuses as-is.

As per coding guidelines, "Chain-scoped identity integrity: all market/token/route identity checks must use canonical identifiers (chainId + market.uniqueKey or chainId + address) in matching, dedup keys, routing, trust/allowlist gates, and shared metadata/cache lookups."

Also applies to: 146-149

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/data-sources/morpho-api/market-whitelist-status.ts` around lines 122 -
128, The code currently validates raw row count before deduping, which allows
overlapping pages to pass then silently drop duplicates; update
fetchMorphoWhitelistStatusesForNetwork to perform deduplication by the canonical
identifier (chainId + market.uniqueKey) immediately after collecting rows, then
compare the deduplicated unique count to totalCount and throw if fewer than
totalCount before returning; ensure the dedup key uses chainId +
market.uniqueKey (or chainId + address where applicable) to satisfy chain-scoped
identity integrity so fetchAllMorphoWhitelistStatuses can continue to use
settledResult.value.statuses unchanged.

};

export const fetchAllMorphoWhitelistStatuses = async (): Promise<MorphoWhitelistStatusRefresh[]> => {
const settledResults = await Promise.allSettled(
MORPHO_SUPPORTED_NETWORKS.map(async (network) => ({
network,
statuses: await fetchMorphoWhitelistStatusesForNetwork(network),
})),
);
const successfulRefreshes: MorphoWhitelistStatusRefresh[] = [];

for (const settledResult of settledResults) {
if (settledResult.status === 'rejected') {
console.warn('[WhitelistStatus] Failed to fetch one network; continuing with cached/partial data.', settledResult.reason);
continue;
}

const statusByKey = new Map<string, MorphoWhitelistStatus>();
for (const status of settledResult.value.statuses) {
statusByKey.set(getMarketIdentityKey(status.chainId, status.uniqueKey), status);
}

successfulRefreshes.push({
network: settledResult.value.network,
statuses: Array.from(statusByKey.values()),
});
}

return successfulRefreshes;
Comment on lines +141 to +157
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail closed when every network refresh fails.

Lines 141-143 swallow each rejection, and Line 157 still returns [] when none succeeded. That makes a full outage look like a successful empty refresh, so any caller that overwrites its snapshot with this result can clear every whitelist flag.

Possible fix
   for (const settledResult of settledResults) {
     if (settledResult.status === 'rejected') {
       console.warn('[WhitelistStatus] Failed to fetch one network; continuing with cached/partial data.', settledResult.reason);
       continue;
     }
@@
     successfulRefreshes.push({
       network: settledResult.value.network,
       statuses: Array.from(statusByKey.values()),
     });
   }
+
+  if (successfulRefreshes.length === 0 && MORPHO_SUPPORTED_NETWORKS.length > 0) {
+    throw new Error('[WhitelistStatus] Failed to refresh whitelist statuses for every Morpho network.');
+  }
 
   return successfulRefreshes;
As per coding guidelines, "Source-discovery failure integrity: market/position discovery hooks must fail closed when both primary and fallback providers fail. Do not convert dual-source failures into empty success; surface typed errors with source and network metadata."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/data-sources/morpho-api/market-whitelist-status.ts` around lines 141 -
157, The current loop swallows per-network rejections and returns an empty
successfulRefreshes array on total failure; change it to detect when no network
succeeded (successfulRefreshes.length === 0) and then throw a descriptive error
instead of returning []; gather the failed network identifiers and their
rejection reasons from the settled results (use the existing settledResult
variable/reason and settled results collection) and throw a typed/annotated
Error (including network and reason metadata) so callers can fail-closed; keep
the existing behavior of continuing on partial failures when at least one
network succeeded.

};
6 changes: 3 additions & 3 deletions src/data-sources/morpho-api/market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ type MorphoApiMarketState = Omit<

type MorphoApiMarket = Omit<Market, 'oracleAddress' | 'whitelisted' | 'state' | 'supplyingVaults'> & {
oracle: { address: string } | null;
listed: boolean;
state: MorphoApiMarketState;
supplyingVaults?: { address: string }[];
};
Expand Down Expand Up @@ -55,11 +54,12 @@ const MORPHO_MARKETS_PAGE_BATCH_SIZE = 4;

// Transform API response to internal Market type
const processMarketData = (market: MorphoApiMarket): Market => {
const { oracle, listed, state, supplyingVaults, ...rest } = market;
const { oracle, state, supplyingVaults, ...rest } = market;
return {
...rest,
oracleAddress: (oracle?.address ?? zeroAddress) as Address,
whitelisted: listed,
// Whitelist status is now overlaid by the dedicated whitelist-status hook.
whitelisted: false,
hasUSDPrice: true,
supplyingVaults: supplyingVaults ?? [],
state: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,9 @@ export function ProActivitiesTable({ chainId, market, onSwitchToBasic }: ProActi

<TableCell className="px-4 py-3 text-right">{renderRowFlow(activity)}</TableCell>

<TableCell className="px-4 py-3 text-sm whitespace-nowrap text-gray-500">{formatActivityTime(activity.timestamp)}</TableCell>
<TableCell className="px-4 py-3 text-sm whitespace-nowrap text-gray-500">
{formatActivityTime(activity.timestamp)}
</TableCell>

<TableCell
className="px-4 py-3 text-right"
Expand Down
19 changes: 12 additions & 7 deletions src/features/markets/components/table/markets-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ function MarketsTable({ currentPage, setCurrentPage, className, tableClassName,
// Get trusted vaults directly from store (no prop drilling!)
const { vaults: trustedVaults } = useTrustedVaults();

const markets = useFilteredMarkets();
const { markets, isLoading: filteredMarketsLoading, isWhitelistUnavailable } = useFilteredMarkets();
const isEmpty = !rawMarkets;
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
const { label: supplyRateLabel } = useRateLabel({ prefix: 'Supply' });
Expand Down Expand Up @@ -83,11 +83,12 @@ function MarketsTable({ currentPage, setCurrentPage, className, tableClassName,
const currentEntries = markets.slice(indexOfFirstEntry, indexOfLastEntry);

const totalPages = Math.ceil(markets.length / entriesPerPage);
const shouldUseFullWidthState = loading || filteredMarketsLoading || isEmpty || markets.length === 0;

const containerClassName = [
'flex flex-col gap-2 pb-4',
loading ? 'w-full' : (className ?? 'w-full'),
loading || isEmpty || markets.length === 0 ? 'items-center' : '',
shouldUseFullWidthState ? 'w-full' : (className ?? 'w-full'),
shouldUseFullWidthState ? 'items-center' : '',
]
.filter((value): value is string => Boolean(value))
.join(' ');
Expand Down Expand Up @@ -130,7 +131,7 @@ function MarketsTable({ currentPage, setCurrentPage, className, tableClassName,
}
className="w-full"
>
{loading ? (
{loading || filteredMarketsLoading ? (
<LoadingScreen
message="Loading Morpho Blue Markets..."
className="min-h-[300px] w-full px-4"
Expand All @@ -141,9 +142,13 @@ function MarketsTable({ currentPage, setCurrentPage, className, tableClassName,
</div>
) : markets.length === 0 ? (
<EmptyScreen
message="No markets found with the current filters"
hint={getEmptyStateHint()}
className="min-h-[300px] container px-[4%]"
message={isWhitelistUnavailable ? 'Morpho whitelist data is unavailable' : 'No markets found with the current filters'}
hint={
isWhitelistUnavailable
? "Hidden unwhitelisted markets is enabled, but Monarch couldn't confirm Morpho whitelist status right now. Try refreshing or temporarily showing unwhitelisted markets."
: getEmptyStateHint()
}
className="min-h-[300px] w-full px-[4%]"
/>
) : (
<Table className={tableClassNames}>
Expand Down
8 changes: 6 additions & 2 deletions src/features/markets/markets-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Chain } from 'viem';

import Header from '@/components/layout/header/Header';
import { Breadcrumbs } from '@/components/shared/breadcrumbs';
import { useFilteredMarkets } from '@/hooks/useFilteredMarkets';
import { useTokensQuery } from '@/hooks/queries/useTokensQuery';
import { useMarketsQuery } from '@/hooks/queries/useMarketsQuery';
import { useMarketsFilters } from '@/stores/useMarketsFilters';
Expand All @@ -20,6 +21,7 @@ export default function Markets() {

// Data fetching with React Query
const { data: rawMarkets, isLoading: loading, refetch } = useMarketsQuery();
const { markets, isLoading: filteredMarketsLoading, isWhitelistUnavailable } = useFilteredMarkets();

const filters = useMarketsFilters();

Expand All @@ -46,8 +48,10 @@ export default function Markets() {

// Effective table view mode - always compact on mobile
const effectiveTableViewMode = isMobile ? 'compact' : tableViewMode;
const isLoadingTableState = loading;
const shouldUseFullWidthTableLayout = isLoadingTableState || effectiveTableViewMode === 'compact';
const isLoadingTableState = loading || filteredMarketsLoading;
const isTableFallbackState = !rawMarkets || markets.length === 0 || isWhitelistUnavailable;
const shouldUseFullWidthTableLayout =
isLoadingTableState || isTableFallbackState || effectiveTableViewMode === 'compact';

// Compute unique collaterals and loan assets for filter dropdowns
useEffect(() => {
Expand Down
21 changes: 19 additions & 2 deletions src/graphql/morpho-api-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ irmAddress
oracle {
address
}
listed
morphoBlue {
id
address
Expand Down Expand Up @@ -103,7 +102,6 @@ export const marketsQuery = `
oracle {
address
}
listed
morphoBlue {
address
chain {
Expand Down Expand Up @@ -155,6 +153,25 @@ export const marketsQuery = `
}
`;

export const marketsWhitelistStatusQuery = `
query getMarketsWhitelistStatus($first: Int, $skip: Int, $where: MarketFilters) {
markets(first: $first, skip: $skip, where: $where) {
items {
uniqueKey
listed
morphoBlue {
chain {
id
}
}
}
pageInfo {
countTotal
}
}
}
`;

export const userPositionsQuery = `
query getUserMarketPositions($address: String!, $chainId: Int) {
userByAddress(address: $address, chainId: $chainId) {
Expand Down
Loading