From be77830fa164e1eb0239bf3cdab1e469c4da7e5a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 1 May 2026 15:18:50 +0800 Subject: [PATCH] feat: adapter alias --- src/contexts/VaultRegistryContext.tsx | 91 ++++++++++++-- src/data-sources/monarch-api/index.ts | 2 + src/data-sources/monarch-api/vaults.ts | 113 ++++++++++++++++++ .../components/charts/borrowers-pie-chart.tsx | 19 +-- .../charts/supplier-positions-chart.tsx | 8 +- .../components/charts/suppliers-pie-chart.tsx | 19 +-- .../queries/useVaultAdapterAliasesQuery.ts | 16 +++ src/hooks/useAddressLabel.ts | 11 +- 8 files changed, 243 insertions(+), 36 deletions(-) create mode 100644 src/hooks/queries/useVaultAdapterAliasesQuery.ts diff --git a/src/contexts/VaultRegistryContext.tsx b/src/contexts/VaultRegistryContext.tsx index 628faffe..fae83946 100644 --- a/src/contexts/VaultRegistryContext.tsx +++ b/src/contexts/VaultRegistryContext.tsx @@ -1,38 +1,111 @@ 'use client'; -import { createContext, useContext, useMemo, type ReactNode } from 'react'; +import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'; import type { MorphoVault } from '@/data-sources/morpho-api/vaults'; import { useAllMorphoVaultsQuery } from '@/hooks/queries/useAllMorphoVaultsQuery'; +import { useVaultAdapterAliasesQuery } from '@/hooks/queries/useVaultAdapterAliasesQuery'; import type { Address } from 'viem'; +type AddressLabel = { + displayName: string; + kind: 'vault' | 'vault-adapter'; + vaultAddress: string; + adapterType?: string; +}; + +type AdapterAddressAlias = { + adapterType: string; + vaultAddress: string; + vaultName: string; +}; + type VaultRegistryContextType = { vaults: MorphoVault[]; loading: boolean; error: Error | null; getVaultByAddress: (address: Address, chainId?: number) => MorphoVault | undefined; + getAddressLabel: (address: Address, chainId?: number) => AddressLabel | undefined; }; const VaultRegistryContext = createContext(undefined); +const getAddressKey = (address: string, chainId: number) => `${chainId}:${address.toLowerCase()}`; + export function VaultRegistryProvider({ children }: { children: ReactNode }) { - const { data: vaults = [], isLoading: loading, error } = useAllMorphoVaultsQuery(); + const { data: vaults = [], isLoading: vaultsLoading, error: vaultsError } = useAllMorphoVaultsQuery(); + const { data: adapterAliases = [], isLoading: adapterAliasesLoading, error: adapterAliasesError } = useVaultAdapterAliasesQuery(); + + const vaultsByScopedAddress = useMemo(() => { + const lookup = new Map(); + for (const vault of vaults) { + lookup.set(getAddressKey(vault.address, vault.chainId), vault); + } + return lookup; + }, [vaults]); + + const adapterAliasesByScopedAddress = useMemo(() => { + const lookup = new Map(); + for (const adapterAlias of adapterAliases) { + lookup.set(getAddressKey(adapterAlias.address, adapterAlias.chainId), { + vaultAddress: adapterAlias.vaultAddress, + vaultName: adapterAlias.vaultName, + adapterType: adapterAlias.adapterType, + }); + } + return lookup; + }, [adapterAliases]); - const getVaultByAddress = useMemo( - () => (address: Address, chainId?: number) => { + const getVaultByAddress = useCallback( + (address: Address, chainId?: number) => { const normalizedAddress = address.toLowerCase(); - return vaults.find((v) => v.address.toLowerCase() === normalizedAddress && (!chainId || v.chainId === chainId)); + if (chainId) { + return vaultsByScopedAddress.get(getAddressKey(normalizedAddress, chainId)); + } + + return vaults.find((vault) => vault.address.toLowerCase() === normalizedAddress); + }, + [vaults, vaultsByScopedAddress], + ); + + const getAddressLabel = useCallback( + (address: Address, chainId?: number): AddressLabel | undefined => { + const vault = getVaultByAddress(address, chainId); + if (vault?.name) { + return { + displayName: vault.name, + kind: 'vault', + vaultAddress: vault.address.toLowerCase(), + }; + } + + if (!chainId) { + return undefined; + } + + const adapterAlias = adapterAliasesByScopedAddress.get(getAddressKey(address, chainId)); + if (!adapterAlias) { + return undefined; + } + + return { + displayName: `${adapterAlias.vaultName} (adapter)`, + kind: 'vault-adapter', + vaultAddress: adapterAlias.vaultAddress, + adapterType: adapterAlias.adapterType, + }; }, - [vaults], + [adapterAliasesByScopedAddress, getVaultByAddress], ); const value = useMemo( () => ({ vaults, - loading, - error, + loading: vaultsLoading || adapterAliasesLoading, + error: vaultsError ?? adapterAliasesError, getVaultByAddress, + getAddressLabel, }), - [vaults, loading, error, getVaultByAddress], + [vaults, vaultsLoading, adapterAliasesLoading, vaultsError, adapterAliasesError, getVaultByAddress, getAddressLabel], ); return {children}; diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index 2d0031a0..ad31d9af 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -28,8 +28,10 @@ export { type TimeRange, } from './transactions'; export { + fetchMonarchVaultAdapterAliases, fetchMonarchVaultDetails, fetchUserVaultV2DetailsAllNetworks, + type VaultAdapterAlias, type VaultAdapterDetails, type UserVaultV2, type VaultV2Cap, diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts index 1cb56ad0..64cdb0cd 100644 --- a/src/data-sources/monarch-api/vaults.ts +++ b/src/data-sources/monarch-api/vaults.ts @@ -17,6 +17,14 @@ export type VaultAdapterDetails = { factoryAddress: string; }; +export type VaultAdapterAlias = { + adapterType: string; + address: string; + chainId: SupportedNetworks; + vaultAddress: string; + vaultName: string; +}; + export const MORPHO_MARKET_ADAPTER_TYPES = ['MorphoMarketV1AdapterV2', 'MorphoMarketV1Adapter'] as const; export const isRecognizedMorphoMarketAdapterType = (adapterType: string | null | undefined): boolean => { @@ -93,6 +101,15 @@ type MonarchAdapterRecord = { vaultAddress: string; }; +type MonarchAdapterAliasRecord = MonarchAdapterRecord & { + vault: { + vaultAddress: string; + chainId: number; + name: string | null; + symbol: string | null; + } | null; +}; + type MonarchVaultsResponse = { data?: { Vault?: MonarchVault[]; @@ -112,6 +129,15 @@ type MonarchAdapterLookupResponse = { }; }; +type MonarchAdapterAliasesResponse = { + data?: { + Adapter?: MonarchAdapterAliasRecord[]; + }; +}; + +const MONARCH_ADAPTER_ALIAS_PAGE_SIZE = 1000; +const MONARCH_ADAPTER_ALIAS_MAX_PAGES = 20; + const MONARCH_VAULT_FIELDS = ` id vaultAddress @@ -230,6 +256,28 @@ const adaptersByAddressQuery = ` } `; +const adapterAliasesQuery = ` + query MonarchVaultAdapterAliases($adapterTypes: [String!]!, $limit: Int!, $offset: Int!) { + Adapter( + where: { adapterType: { _in: $adapterTypes } } + order_by: [{ createdAt: desc }] + limit: $limit + offset: $offset + ) { + adapterAddress + adapterType + chainId + vaultAddress + vault { + vaultAddress + chainId + name + symbol + } + } + } +`; + export const fetchUserVaultV2DetailsAllNetworks = async (owner: string): Promise => { const response = await monarchGraphqlFetcher(userVaultsQuery, { owner: owner.toLowerCase(), @@ -285,3 +333,68 @@ export const fetchMonarchAdaptersByAddress = async ( return true; }); }; + +const transformAdapterAliasRecord = (adapter: MonarchAdapterAliasRecord): VaultAdapterAlias | null => { + if (!isRecognizedMorphoMarketAdapterType(adapter.adapterType)) { + return null; + } + + const chainId = toSupportedNetwork(adapter.chainId); + if (!chainId || (adapter.vault && adapter.vault.chainId !== adapter.chainId)) { + return null; + } + + const vaultAddress = normalizeAddress(adapter.vault?.vaultAddress ?? adapter.vaultAddress); + const vaultName = adapter.vault?.name?.trim() || adapter.vault?.symbol?.trim() || ''; + if (!vaultName) { + return null; + } + + return { + address: normalizeAddress(adapter.adapterAddress), + adapterType: adapter.adapterType, + chainId, + vaultAddress, + vaultName, + }; +}; + +export const fetchMonarchVaultAdapterAliases = async (): Promise => { + try { + const aliases: VaultAdapterAlias[] = []; + const seenKeys = new Set(); + + for (let page = 0; page < MONARCH_ADAPTER_ALIAS_MAX_PAGES; page++) { + const response = await monarchGraphqlFetcher(adapterAliasesQuery, { + adapterTypes: MORPHO_MARKET_ADAPTER_TYPES, + limit: MONARCH_ADAPTER_ALIAS_PAGE_SIZE, + offset: page * MONARCH_ADAPTER_ALIAS_PAGE_SIZE, + }); + + const records = response.data?.Adapter ?? []; + for (const record of records) { + const alias = transformAdapterAliasRecord(record); + if (!alias) { + continue; + } + + const key = `${alias.chainId}:${alias.address}`; + if (seenKeys.has(key)) { + continue; + } + + seenKeys.add(key); + aliases.push(alias); + } + + if (records.length < MONARCH_ADAPTER_ALIAS_PAGE_SIZE) { + break; + } + } + + return aliases; + } catch (error) { + console.warn('Error fetching Monarch vault adapter aliases:', error); + return []; + } +}; diff --git a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx index 06a90730..f8ec484a 100644 --- a/src/features/market-detail/components/charts/borrowers-pie-chart.tsx +++ b/src/features/market-detail/components/charts/borrowers-pie-chart.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { Address } from 'viem'; import { formatUnits } from 'viem'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; @@ -98,16 +98,19 @@ function BorrowersPieTooltip({ export function BorrowersPieChart({ chainId, market, oraclePrice }: BorrowersPieChartProps) { const { data: borrowers, isLoading, totalCount } = useAllMarketBorrowers(market.uniqueKey, chainId, market.state); - const { getVaultByAddress } = useVaultRegistry(); + const { getAddressLabel } = useVaultRegistry(); const [expandedOther, setExpandedOther] = useState(false); const chartColors = useChartColors(); - // Helper to get display name for an address (vault name or shortened address) - const getDisplayName = (address: string): string => { - const vault = getVaultByAddress(address as Address, chainId); - if (vault?.name) return vault.name; - return getSlicedAddress(address as `0x${string}`); - }; + // Helper to get display name for an address (vault/adapter label or shortened address) + const getDisplayName = useCallback( + (address: string): string => { + const addressLabel = getAddressLabel(address as Address, chainId); + if (addressLabel?.displayName) return addressLabel.displayName; + return getSlicedAddress(address as `0x${string}`); + }, + [getAddressLabel, chainId], + ); const pieData = useMemo(() => { if (!borrowers || borrowers.length === 0) return []; diff --git a/src/features/market-detail/components/charts/supplier-positions-chart.tsx b/src/features/market-detail/components/charts/supplier-positions-chart.tsx index 3c914dee..c4c9d8e3 100644 --- a/src/features/market-detail/components/charts/supplier-positions-chart.tsx +++ b/src/features/market-detail/components/charts/supplier-positions-chart.tsx @@ -84,7 +84,7 @@ export function SupplierPositionsChart({ marketId, chainId, market }: SupplierPo const selectedTimeframe = useMarketDetailChartState((s) => s.selectedTimeframe); const selectedTimeRange = useMarketDetailChartState((s) => s.selectedTimeRange); const chartColors = useChartColors(); - const { getVaultByAddress } = useVaultRegistry(); + const { getAddressLabel } = useVaultRegistry(); const { data: suppliers, isLoading: suppliersLoading } = useAllMarketSuppliers(market.uniqueKey, chainId); @@ -120,11 +120,11 @@ export function SupplierPositionsChart({ marketId, chainId, market }: SupplierPo // Get display name for a supplier address const getDisplayName = useCallback( (address: string): string => { - const vault = getVaultByAddress(address as Address, chainId); - if (vault?.name) return vault.name; + const addressLabel = getAddressLabel(address as Address, chainId); + if (addressLabel?.displayName) return addressLabel.displayName; return getSlicedAddress(address as `0x${string}`); }, - [getVaultByAddress, chainId], + [getAddressLabel, chainId], ); const handleLegendClick = useCallback( diff --git a/src/features/market-detail/components/charts/suppliers-pie-chart.tsx b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx index f5281700..754ab3f4 100644 --- a/src/features/market-detail/components/charts/suppliers-pie-chart.tsx +++ b/src/features/market-detail/components/charts/suppliers-pie-chart.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { Address } from 'viem'; import { formatUnits } from 'viem'; import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; @@ -73,16 +73,19 @@ function SuppliersPieTooltip({ export function SuppliersPieChart({ chainId, market }: SuppliersPieChartProps) { const { data: suppliers, isLoading, totalCount } = useAllMarketSuppliers(market.uniqueKey, chainId); - const { getVaultByAddress } = useVaultRegistry(); + const { getAddressLabel } = useVaultRegistry(); const [expandedOther, setExpandedOther] = useState(false); const chartColors = useChartColors(); - // Helper to get display name for an address (vault name or shortened address) - const getDisplayName = (address: string): string => { - const vault = getVaultByAddress(address as Address, chainId); - if (vault?.name) return vault.name; - return getSlicedAddress(address as `0x${string}`); - }; + // Helper to get display name for an address (vault/adapter label or shortened address) + const getDisplayName = useCallback( + (address: string): string => { + const addressLabel = getAddressLabel(address as Address, chainId); + if (addressLabel?.displayName) return addressLabel.displayName; + return getSlicedAddress(address as `0x${string}`); + }, + [getAddressLabel, chainId], + ); const pieData = useMemo(() => { if (!suppliers || suppliers.length === 0) return []; diff --git a/src/hooks/queries/useVaultAdapterAliasesQuery.ts b/src/hooks/queries/useVaultAdapterAliasesQuery.ts new file mode 100644 index 00000000..086b805b --- /dev/null +++ b/src/hooks/queries/useVaultAdapterAliasesQuery.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchMonarchVaultAdapterAliases, type VaultAdapterAlias } from '@/data-sources/monarch-api/vaults'; + +const VAULT_ADAPTER_ALIASES_STALE_TIME_MS = 10 * 60 * 1000; +const VAULT_ADAPTER_ALIASES_GC_TIME_MS = 30 * 60 * 1000; + +export const useVaultAdapterAliasesQuery = () => { + return useQuery({ + queryKey: ['monarch-vault-adapter-aliases'], + queryFn: fetchMonarchVaultAdapterAliases, + staleTime: VAULT_ADAPTER_ALIASES_STALE_TIME_MS, + gcTime: VAULT_ADAPTER_ALIASES_GC_TIME_MS, + refetchOnWindowFocus: false, + retry: false, + }); +}; diff --git a/src/hooks/useAddressLabel.ts b/src/hooks/useAddressLabel.ts index 6064cdb5..189fbe24 100644 --- a/src/hooks/useAddressLabel.ts +++ b/src/hooks/useAddressLabel.ts @@ -10,24 +10,21 @@ type UseAddressLabelReturn = { /** * Hook to resolve address labels in priority order: - * 1. Vault name (if address is a known vault) + * 1. Vault name (if address is a known vault or recognized vault adapter) * 2. ENS name (handled by Name component) * 3. Shortened address (0x1234...5678) */ export function useAddressLabel(address: Address, chainId?: number): UseAddressLabelReturn { - const { getVaultByAddress } = useVaultRegistry(); + const { getAddressLabel } = useVaultRegistry(); - const vaultName = useMemo(() => { - const vault = getVaultByAddress(address, chainId); - return vault?.name; - }, [address, chainId, getVaultByAddress]); + const addressLabel = useMemo(() => getAddressLabel(address, chainId), [address, chainId, getAddressLabel]); const shortAddress = useMemo(() => { return getSlicedAddress(address as `0x${string}`); }, [address]); return { - vaultName, + vaultName: addressLabel?.displayName, shortAddress, }; }