From b5af8407176aaf54269676878941e57167624318 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 17:49:56 +0800 Subject: [PATCH 1/7] refactor: remove subgraph indexer --- AGENTS.md | 1 + app/api/monarch/graphql/route.ts | 39 +++ app/api/monarch/metrics/route.ts | 8 +- app/api/monarch/utils.ts | 17 +- src/components/providers/QueryProvider.tsx | 1 - src/data-sources/monarch-api/fetchers.ts | 41 +++ src/data-sources/monarch-api/index.ts | 10 + src/data-sources/monarch-api/vaults.ts | 232 +++++++++++++++++ src/data-sources/morpho-api/v2-vaults.ts | 228 ----------------- src/data-sources/morpho-api/vaults.ts | 50 +++- .../subgraph/morpho-market-v1-adapters.ts | 45 ---- src/data-sources/subgraph/v2-vaults.ts | 90 ------- .../modals/vault-initialization-modal.tsx | 29 +-- .../vault-detail/settings/EditCaps.tsx | 2 +- src/features/autovault/vault-list-view.tsx | 4 +- .../components/user-vaults-table.tsx | 2 +- .../components/vault-allocation-detail.tsx | 2 +- .../morpho-market-v1-adapter-queries.ts | 10 - src/graphql/morpho-v2-subgraph-queries.ts | 15 -- src/graphql/vault-queries.ts | 15 ++ src/hooks/queries/useAllocations.ts | 2 +- src/hooks/queries/useUserVaultsV2Query.ts | 71 ++---- src/hooks/useMorphoMarketV1Adapters.ts | 50 ++-- src/hooks/usePortfolioValue.ts | 2 +- src/hooks/useVaultAllocations.ts | 2 +- src/hooks/useVaultHistoricalApy.ts | 2 +- src/hooks/useVaultV2.ts | 18 +- src/hooks/useVaultV2Data.ts | 240 +++++++----------- src/stores/useVaultKeysCache.ts | 170 ------------- src/utils/networks.ts | 6 +- src/utils/portfolio.ts | 2 +- 31 files changed, 548 insertions(+), 858 deletions(-) create mode 100644 app/api/monarch/graphql/route.ts create mode 100644 src/data-sources/monarch-api/fetchers.ts create mode 100644 src/data-sources/monarch-api/index.ts create mode 100644 src/data-sources/monarch-api/vaults.ts delete mode 100644 src/data-sources/morpho-api/v2-vaults.ts delete mode 100644 src/data-sources/subgraph/morpho-market-v1-adapters.ts delete mode 100644 src/data-sources/subgraph/v2-vaults.ts delete mode 100644 src/graphql/morpho-market-v1-adapter-queries.ts delete mode 100644 src/graphql/morpho-v2-subgraph-queries.ts delete mode 100644 src/stores/useVaultKeysCache.ts diff --git a/AGENTS.md b/AGENTS.md index 34c07b5c..6d15b0a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,7 @@ When touching transaction and position flows, validation MUST include all releva 29. **Preview prop integrity**: any position/risk preview component that separates current and projected props must receive quote- or input-derived projected balances through dedicated `projected*` props while preserving live balances in `current*` props, so amount rows, LTV deltas, and liquidation metrics stay synchronized instead of mixing current and projected states. 30. **Fee preview consistency**: transaction previews that show protocol/app fees must derive token/USD display from shared fee-display helpers, use compact token amounts with explicit full-value hover content, threshold tiny USD values as `< $0.01` while preserving exact USD on hover, and avoid ad hoc per-modal formatting drift. 31. **Market list first-paint integrity**: shared multi-chain market list queries must not let a single slow chain or slow fallback path block first paint indefinitely; network fetches should use bounded request timeouts that account for fallback coverage, and any fallback path used for first paint must preserve market completeness (no truncated `first: 1000` fallback). Once page 1 reveals total count, remaining pagination should be fetched in parallel or bounded parallel batches instead of strict sequential loops. +32. **Canonical vault-metadata source integrity**: when Monarch API exposes chain-scoped V2 vault metadata (owner, curator, asset, allocators, sentinels, adapters, caps), use it as the primary read chokepoint. Do not rebuild the same view through per-chain subgraphs, slow Morpho API fanout, local key caches, or event parsing except for narrow RPC fallback while a fresh write has not been indexed yet. ### REQUIRED: Regression Rule Capture diff --git a/app/api/monarch/graphql/route.ts b/app/api/monarch/graphql/route.ts new file mode 100644 index 00000000..f9251062 --- /dev/null +++ b/app/api/monarch/graphql/route.ts @@ -0,0 +1,39 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { MONARCH_GRAPHQL_API_KEY, getMonarchGraphqlUrl } from '../utils'; +import { reportApiRouteError } from '@/utils/sentry-server'; + +export async function POST(request: NextRequest) { + if (!MONARCH_GRAPHQL_API_KEY) { + console.error('[Monarch GraphQL API] Missing NEXT_PUBLIC_MONARCH_API_KEY'); + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); + } + + try { + const body = await request.json(); + const response = await fetch(getMonarchGraphqlUrl(), { + method: 'POST', + headers: { + Authorization: `Bearer ${MONARCH_GRAPHQL_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Monarch GraphQL API] Error:', response.status, errorText); + return NextResponse.json({ error: 'Failed to fetch Monarch GraphQL data' }, { status: response.status }); + } + + return NextResponse.json(await response.json()); + } catch (error) { + reportApiRouteError(error, { + route: '/api/monarch/graphql', + method: 'POST', + status: 500, + }); + console.error('[Monarch GraphQL API] Failed to fetch:', error); + return NextResponse.json({ error: 'Failed to fetch Monarch GraphQL data' }, { status: 500 }); + } +} diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index f737c6d2..ce661934 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -1,9 +1,9 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { MONARCH_API_KEY, getMonarchUrl } from '../utils'; +import { MONARCH_METRICS_API_KEY, getMonarchMetricsUrl } from '../utils'; import { reportApiRouteError } from '@/utils/sentry-server'; export async function GET(req: NextRequest) { - if (!MONARCH_API_KEY) { + if (!MONARCH_METRICS_API_KEY) { console.error('[Monarch Metrics API] Missing MONARCH_API_KEY'); return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } @@ -11,14 +11,14 @@ export async function GET(req: NextRequest) { const searchParams = req.nextUrl.searchParams; try { - const url = getMonarchUrl('/v1/markets/metrics'); + const url = getMonarchMetricsUrl('/v1/markets/metrics'); for (const key of ['chain_id', 'sort_by', 'sort_order', 'limit', 'offset']) { const value = searchParams.get(key); if (value) url.searchParams.set(key, value); } const response = await fetch(url, { - headers: { 'X-API-Key': MONARCH_API_KEY }, + headers: { 'X-API-Key': MONARCH_METRICS_API_KEY }, cache: 'no-store', }); diff --git a/app/api/monarch/utils.ts b/app/api/monarch/utils.ts index cea2f7d9..af3f2f6c 100644 --- a/app/api/monarch/utils.ts +++ b/app/api/monarch/utils.ts @@ -1,7 +1,14 @@ -export const MONARCH_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT; -export const MONARCH_API_KEY = process.env.MONARCH_API_KEY; +export const MONARCH_METRICS_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT; +export const MONARCH_METRICS_API_KEY = process.env.MONARCH_API_KEY; +export const MONARCH_GRAPHQL_API_ENDPOINT = process.env.NEXT_PUBLIC_MONARCH_API_NEW; +export const MONARCH_GRAPHQL_API_KEY = process.env.NEXT_PUBLIC_MONARCH_API_KEY; -export const getMonarchUrl = (path: string): URL => { - if (!MONARCH_API_ENDPOINT) throw new Error('MONARCH_API_ENDPOINT not configured'); - return new URL(path, MONARCH_API_ENDPOINT.replace(/\/$/, '')); +export const getMonarchMetricsUrl = (path: string): URL => { + if (!MONARCH_METRICS_API_ENDPOINT) throw new Error('MONARCH_API_ENDPOINT not configured'); + return new URL(path, MONARCH_METRICS_API_ENDPOINT.replace(/\/$/, '')); +}; + +export const getMonarchGraphqlUrl = (): URL => { + if (!MONARCH_GRAPHQL_API_ENDPOINT) throw new Error('NEXT_PUBLIC_MONARCH_API_NEW not configured'); + return new URL(MONARCH_GRAPHQL_API_ENDPOINT); }; diff --git a/src/components/providers/QueryProvider.tsx b/src/components/providers/QueryProvider.tsx index b9d04222..acb0de9c 100644 --- a/src/components/providers/QueryProvider.tsx +++ b/src/components/providers/QueryProvider.tsx @@ -21,7 +21,6 @@ const ACTIONABLE_QUERY_ROOT_KEYS = new Set([ 'markets', 'monarch-transactions', 'merkl-campaigns', - 'morpho-market-v1-adapters', 'oracle-data', 'oracle-metadata', 'morpho-vaults', diff --git a/src/data-sources/monarch-api/fetchers.ts b/src/data-sources/monarch-api/fetchers.ts new file mode 100644 index 00000000..712e138d --- /dev/null +++ b/src/data-sources/monarch-api/fetchers.ts @@ -0,0 +1,41 @@ +type GraphQLVariables = Record; + +type GraphQLError = { + message?: string; +}; + +type MonarchGraphqlFetcherOptions = { + signal?: AbortSignal; +}; + +export const monarchGraphqlFetcher = async >( + query: string, + variables: GraphQLVariables = {}, + options: MonarchGraphqlFetcherOptions = {}, +): Promise => { + const response = await fetch('/api/monarch/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + cache: 'no-store', + signal: options.signal, + }); + + if (!response.ok) { + throw new Error(`Monarch API request failed: ${response.status} ${response.statusText}`); + } + + const result = (await response.json()) as T & { errors?: GraphQLError[] }; + + if (result.errors && result.errors.length > 0) { + const message = result.errors.map((error) => error.message).filter(Boolean).join('; ') || 'Unknown Monarch GraphQL error'; + throw new Error(message); + } + + return result; +}; diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts new file mode 100644 index 00000000..a7623bf8 --- /dev/null +++ b/src/data-sources/monarch-api/index.ts @@ -0,0 +1,10 @@ +export { monarchGraphqlFetcher } from './fetchers'; +export { + fetchMonarchVaultDetails, + fetchUserVaultV2AddressesAllNetworks, + fetchUserVaultV2DetailsAllNetworks, + type UserVaultV2, + type UserVaultV2Address, + type VaultV2Cap, + type VaultV2Details, +} from './vaults'; diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts new file mode 100644 index 00000000..b5f201a8 --- /dev/null +++ b/src/data-sources/monarch-api/vaults.ts @@ -0,0 +1,232 @@ +import type { Address } from 'viem'; +import { ALL_SUPPORTED_NETWORKS, type SupportedNetworks } from '@/utils/networks'; +import { monarchGraphqlFetcher } from './fetchers'; + +export type VaultV2Cap = { + relativeCap: string; + absoluteCap: string; + capId: string; + idParams: string; + oldRelativeCap?: string; + oldAbsoluteCap?: string; +}; + +export type VaultV2Details = { + id: string; + address: string; + asset: string; + symbol: string; + name: string; + curator: string; + owner: string; + allocators: string[]; + sentinels: string[]; + caps: VaultV2Cap[]; + adapters: string[]; + avgApy?: number; +}; + +export type UserVaultV2Address = { + address: string; + networkId: SupportedNetworks; +}; + +export type UserVaultV2 = VaultV2Details & { + networkId: SupportedNetworks; + balance?: bigint; + adapter?: Address; + actualApy?: number; +}; + +type MonarchVaultAllocator = { + account: string; + isAllocator: boolean; +}; + +type MonarchVaultSentinel = { + account: string; + isSentinel: boolean; +}; + +type MonarchVaultAdapter = { + adapterAddress: string; + isActive: boolean; +}; + +type MonarchVaultCap = { + id: string; + paramId: string; + paramIdData: string; + absoluteCap: string; + relativeCap: string; +}; + +type MonarchVault = { + id: string; + vaultAddress: string; + chainId: number; + asset: string; + symbol: string | null; + name: string | null; + owner: string; + curator: string | null; + allocators: MonarchVaultAllocator[]; + sentinels: MonarchVaultSentinel[]; + adapters: MonarchVaultAdapter[]; + caps: MonarchVaultCap[]; +}; + +type MonarchVaultsResponse = { + data?: { + Vault?: MonarchVault[]; + }; +}; + +type MonarchVaultAddressRecord = Pick; + +type MonarchVaultAddressesResponse = { + data?: { + Vault?: MonarchVaultAddressRecord[]; + }; +}; + +const MONARCH_VAULT_FIELDS = ` + id + vaultAddress + chainId + asset + symbol + name + owner + curator + allocators { + account + isAllocator + } + sentinels { + account + isSentinel + } + adapters { + adapterAddress + isActive + } + caps { + id + paramId + paramIdData + absoluteCap + relativeCap + } +`; + +const MONARCH_VAULT_ADDRESS_FIELDS = ` + vaultAddress + chainId +`; + +const normalizeAddress = (value: string | null | undefined): string => value?.toLowerCase() ?? ''; + +const toSupportedNetwork = (chainId: number): SupportedNetworks | null => { + return ALL_SUPPORTED_NETWORKS.includes(chainId as SupportedNetworks) ? (chainId as SupportedNetworks) : null; +}; + +const transformCap = (cap: MonarchVaultCap): VaultV2Cap => { + return { + capId: cap.paramId, + idParams: cap.paramIdData, + absoluteCap: cap.absoluteCap, + relativeCap: cap.relativeCap, + }; +}; + +const transformVault = (vault: MonarchVault): UserVaultV2 | null => { + const networkId = toSupportedNetwork(vault.chainId); + if (!networkId) { + return null; + } + + const activeAdapters = vault.adapters.filter((adapter) => adapter.isActive).map((adapter) => normalizeAddress(adapter.adapterAddress)); + const activeAllocators = vault.allocators.filter((allocator) => allocator.isAllocator).map((allocator) => normalizeAddress(allocator.account)); + const activeSentinels = vault.sentinels.filter((sentinel) => sentinel.isSentinel).map((sentinel) => normalizeAddress(sentinel.account)); + + return { + id: vault.id, + address: normalizeAddress(vault.vaultAddress), + asset: normalizeAddress(vault.asset), + symbol: vault.symbol ?? '', + name: vault.name ?? '', + curator: normalizeAddress(vault.curator), + owner: normalizeAddress(vault.owner), + allocators: activeAllocators, + sentinels: activeSentinels, + caps: vault.caps.map(transformCap), + adapters: activeAdapters, + adapter: activeAdapters[0] as Address | undefined, + networkId, + }; +}; + +const transformVaultAddress = (vault: MonarchVaultAddressRecord): UserVaultV2Address | null => { + const networkId = toSupportedNetwork(vault.chainId); + if (!networkId) { + return null; + } + + return { + address: normalizeAddress(vault.vaultAddress), + networkId, + }; +}; + +const userVaultsQuery = ` + query MonarchUserVaults($owner: String!) { + Vault(where: { owner: { _eq: $owner } }, order_by: [{ lastUpdate: desc }]) { + ${MONARCH_VAULT_FIELDS} + } + } +`; + +const userVaultAddressesQuery = ` + query MonarchUserVaultAddresses($owner: String!) { + Vault(where: { owner: { _eq: $owner } }, order_by: [{ lastUpdate: desc }]) { + ${MONARCH_VAULT_ADDRESS_FIELDS} + } + } +`; + +const vaultByAddressQuery = ` + query MonarchVaultByAddress($address: String!, $chainId: Int!) { + Vault(where: { vaultAddress: { _eq: $address }, chainId: { _eq: $chainId } }, limit: 1) { + ${MONARCH_VAULT_FIELDS} + } + } +`; + +export const fetchUserVaultV2DetailsAllNetworks = async (owner: string): Promise => { + const response = await monarchGraphqlFetcher(userVaultsQuery, { + owner: owner.toLowerCase(), + }); + + const vaults = response.data?.Vault ?? []; + return vaults.map(transformVault).filter((vault): vault is UserVaultV2 => vault !== null); +}; + +export const fetchUserVaultV2AddressesAllNetworks = async (owner: string): Promise => { + const response = await monarchGraphqlFetcher(userVaultAddressesQuery, { + owner: owner.toLowerCase(), + }); + + const vaults = response.data?.Vault ?? []; + return vaults.map(transformVaultAddress).filter((vault): vault is UserVaultV2Address => vault !== null); +}; + +export const fetchMonarchVaultDetails = async (vaultAddress: string, chainId: SupportedNetworks): Promise => { + const response = await monarchGraphqlFetcher(vaultByAddressQuery, { + address: vaultAddress.toLowerCase(), + chainId, + }); + + const vault = response.data?.Vault?.[0]; + return vault ? transformVault(vault) : null; +}; diff --git a/src/data-sources/morpho-api/v2-vaults.ts b/src/data-sources/morpho-api/v2-vaults.ts deleted file mode 100644 index 102f7512..00000000 --- a/src/data-sources/morpho-api/v2-vaults.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { vaultV2Query } from '@/graphql/morpho-api-queries'; -import type { SupportedNetworks } from '@/utils/networks'; -import { morphoGraphqlFetcher } from './fetchers'; - -// Re-export types from subgraph to maintain compatibility -// These types match the API response structure -export type VaultV2Cap = { - relativeCap: string; - absoluteCap: string; - capId: string; - idParams: string; - oldRelativeCap?: string; // For delta calculation - oldAbsoluteCap?: string; // For delta calculation -}; - -export type VaultV2Details = { - id: string; - address: string; - asset: string; - symbol: string; - name: string; - curator: string; - owner: string; - allocators: string[]; - sentinels: string[]; - caps: VaultV2Cap[]; - adapters: string[]; - avgApy?: number; -}; - -// API response types -type ApiVaultV2Cap = { - id: string; - idData: string; - absoluteCap: number | string; - relativeCap: string; -}; - -type ApiVaultV2 = { - id: string; - address: string; - name: string; - symbol: string; - avgApy: number; - asset: { - id: string; - address: string; - symbol: string; - name: string; - decimals: number; - }; - curator: { - address: string; - } | null; - owner: { - address: string; - } | null; - allocators: { - allocator: { - address: string; - }; - }[]; - adapters: { - items: { - address: string; - }[]; - }; - caps: { - items: ApiVaultV2Cap[]; - }; -}; - -type VaultV2ApiResponse = { - data?: { - vaultV2ByAddress?: ApiVaultV2 | null; - }; - errors?: { message: string }[]; -}; - -/** - * Transforms API cap response to internal VaultV2Cap format - */ -function transformCap(apiCap: ApiVaultV2Cap): VaultV2Cap { - return { - capId: apiCap.id, - idParams: apiCap.idData, - absoluteCap: String(apiCap.absoluteCap), - relativeCap: apiCap.relativeCap, - }; -} - -/** - * Transforms API vault response to internal VaultV2Details format - */ -function transformVault(apiVault: ApiVaultV2): VaultV2Details { - return { - id: apiVault.id, - address: apiVault.address, - asset: apiVault.asset.address, - symbol: apiVault.symbol, - name: apiVault.name, - curator: apiVault.curator?.address ?? '', - owner: apiVault.owner?.address ?? '', - allocators: apiVault.allocators.map((a) => a.allocator.address), - sentinels: [], // Not available in API response - caps: apiVault.caps.items.map(transformCap), - adapters: apiVault.adapters.items.map((a) => a.address), - avgApy: apiVault.avgApy, - }; -} - -/** - * Core function to fetch VaultV2 details from Morpho API - * Handles both single and multiple vault addresses - * Note: API only accepts one address at a time, so we fetch individually - * - * @param vaultAddresses - Array of vault addresses - * @param network - The network/chain ID - * @returns Array of VaultV2Details - */ -const fetchVaultV2DetailsCore = async (vaultAddresses: string[], network: SupportedNetworks): Promise => { - if (vaultAddresses.length === 0) { - return []; - } - - try { - // Fetch each vault individually since API only accepts single address - const promises = vaultAddresses.map(async (address) => { - const variables = { - address: address.toLowerCase(), - chainId: network, - }; - - const response = await morphoGraphqlFetcher(vaultV2Query, variables); - - // Handle NOT_FOUND - vault not found in API - if (!response) { - return null; - } - - if (response.errors && response.errors.length > 0) { - console.error('GraphQL errors:', response.errors); - return null; - } - - const vault = response.data?.vaultV2ByAddress; - if (!vault) { - // Vault not found in API (might not be initialized yet) - return null; - } - - return transformVault(vault); - }); - - const results = await Promise.all(promises); - return results.filter((vault): vault is VaultV2Details => vault !== null); - } catch (error) { - console.error(`Error fetching V2 vault details on network ${network}:`, error); - return []; - } -}; - -/** - * Fetches a single VaultV2 details from Morpho API - * - * @param vaultAddress - The vault address - * @param network - The network/chain ID - * @returns VaultV2Details or null if not found - */ -export const fetchVaultV2Details = async (vaultAddress: string, network: SupportedNetworks): Promise => { - const results = await fetchVaultV2DetailsCore([vaultAddress], network); - return results.length > 0 ? results[0] : null; -}; - -/** - * Fetches multiple VaultV2 details from Morpho API for a single network - * - * @param vaultAddresses - Array of vault addresses - * @param network - The network/chain ID - * @returns Array of VaultV2Details - */ -export const fetchMultipleVaultV2Details = async (vaultAddresses: string[], network: SupportedNetworks): Promise => { - return fetchVaultV2DetailsCore(vaultAddresses, network); -}; - -/** - * Fetches multiple VaultV2 details from Morpho API across multiple networks - * Groups addresses by network and fetches them efficiently - * - * @param vaultAddressesWithNetwork - Array of vault addresses with their network IDs - * @returns Array of VaultV2Details with networkId - */ -export const fetchMultipleVaultV2DetailsAcrossNetworks = async ( - vaultAddressesWithNetwork: { - address: string; - networkId: SupportedNetworks; - }[], -): Promise<(VaultV2Details & { networkId: SupportedNetworks })[]> => { - if (vaultAddressesWithNetwork.length === 0) { - return []; - } - - // Group addresses by network - const addressesByNetwork = vaultAddressesWithNetwork.reduce( - (acc, item) => { - if (!acc[item.networkId]) { - acc[item.networkId] = []; - } - acc[item.networkId].push(item.address); - return acc; - }, - {} as Record, - ); - - // Fetch details for each network in parallel - const promises = Object.entries(addressesByNetwork).map(async ([networkIdStr, addresses]) => { - const networkId = Number(networkIdStr) as SupportedNetworks; - const details = await fetchMultipleVaultV2Details(addresses, networkId); - // Add network ID to each vault detail - return details.map((detail) => ({ - ...detail, - networkId, - })); - }); - - const results = await Promise.all(promises); - return results.flat(); -}; diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts index 7c6a178b..961184a7 100644 --- a/src/data-sources/morpho-api/vaults.ts +++ b/src/data-sources/morpho-api/vaults.ts @@ -1,4 +1,5 @@ -import { allVaultsQuery } from '@/graphql/vault-queries'; +import { allVaultsQuery, vaultApysQuery } from '@/graphql/vault-queries'; +import type { UserVaultV2Address } from '@/data-sources/monarch-api/vaults'; import { morphoGraphqlFetcher } from './fetchers'; // Constants for Morpho vault fetching @@ -22,6 +23,7 @@ type ApiVault = { id: number; }; name: string; + avgApy?: number | null; state: { totalAssets: string; }; @@ -40,6 +42,17 @@ type AllVaultsApiResponse = { errors?: { message: string }[]; }; +type VaultApysApiResponse = { + data?: { + vaults?: { + items?: Pick[]; + }; + }; + errors?: { message: string }[]; +}; + +const getVaultApyKey = (address: string, chainId: number) => `${address.toLowerCase()}-${chainId}`; + /** * Transforms API vault response to internal MorphoVault format */ @@ -88,3 +101,38 @@ export const fetchAllMorphoVaults = async (): Promise => { return []; } }; + +export const fetchMorphoVaultApys = async (vaults: UserVaultV2Address[]): Promise> => { + if (vaults.length === 0) { + return new Map(); + } + + try { + const response = await morphoGraphqlFetcher(vaultApysQuery, { + first: vaults.length, + where: { + address_in: vaults.map((vault) => vault.address.toLowerCase()), + chainId_in: [...new Set(vaults.map((vault) => vault.networkId))], + }, + }); + + if (!response) { + return new Map(); + } + + const items = response.data?.vaults?.items ?? []; + const apys = new Map(); + + for (const vault of items) { + if (vault.avgApy === null || vault.avgApy === undefined) { + continue; + } + apys.set(getVaultApyKey(vault.address, vault.chain.id), vault.avgApy); + } + + return apys; + } catch (error) { + console.warn('Error fetching Morpho vault APYs:', error); + return new Map(); + } +}; diff --git a/src/data-sources/subgraph/morpho-market-v1-adapters.ts b/src/data-sources/subgraph/morpho-market-v1-adapters.ts deleted file mode 100644 index 908176ee..00000000 --- a/src/data-sources/subgraph/morpho-market-v1-adapters.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Address } from 'viem'; -import { morphoMarketV1AdaptersQuery } from '@/graphql/morpho-market-v1-adapter-queries'; -import { subgraphGraphqlFetcher } from './fetchers'; - -type MorphoMarketV1AdaptersResponse = { - data?: { - createMorphoMarketV1Adapters: { - id: string; - parentVault: string; - morpho: string; - morphoMarketV1Adapter: string; - }[]; - }; -}; - -export type MorphoMarketV1AdapterRecord = { - id: string; - adapter: Address; - parentVault: Address; - morpho: Address; -}; - -export async function fetchMorphoMarketV1Adapters({ - subgraphUrl, - parentVault, - morpho, -}: { - subgraphUrl: string; - parentVault: Address; - morpho: Address; -}): Promise { - const response = await subgraphGraphqlFetcher(subgraphUrl, morphoMarketV1AdaptersQuery, { - parentVault: parentVault.toLowerCase(), - morpho: morpho.toLowerCase(), - }); - - const adapters = response.data?.createMorphoMarketV1Adapters ?? []; - - return adapters.map((adapter) => ({ - id: adapter.id, - adapter: adapter.morphoMarketV1Adapter as Address, - parentVault: adapter.parentVault as Address, - morpho: adapter.morpho as Address, - })); -} diff --git a/src/data-sources/subgraph/v2-vaults.ts b/src/data-sources/subgraph/v2-vaults.ts deleted file mode 100644 index e1270e27..00000000 --- a/src/data-sources/subgraph/v2-vaults.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Address } from 'viem'; -import type { VaultV2Details } from '@/data-sources/morpho-api/v2-vaults'; -import { userVaultsV2AddressesQuery } from '@/graphql/morpho-v2-subgraph-queries'; -import { type SupportedNetworks, getAgentConfig, networks, isAgentAvailable } from '@/utils/networks'; -import { subgraphGraphqlFetcher } from './fetchers'; - -// Simplified subgraph response for vault addresses -type SubgraphVaultV2Address = { - id: string; // Vault address -}; - -// Response structure for user vaults query (only addresses) -type SubgraphUserVaultsV2Response = { - data: { - vaultV2S: SubgraphVaultV2Address[]; - }; - errors?: any[]; -}; - -// Vault address with network information -export type UserVaultV2Address = { - address: string; - networkId: SupportedNetworks; -}; - -// User vault with full details and network info -// This is used by the autovault page to display user's vaults -export type UserVaultV2 = VaultV2Details & { - networkId: SupportedNetworks; - balance?: bigint; // User's redeemable assets (from previewRedeem) - adapter?: Address; // MorphoMarketV1Adapter address - actualApy?: number; // Historical APY for the selected period -}; - -/** - * Fetches only vault addresses owned by a user from the subgraph - * This is the first step - get addresses, then fetch details from Morpho API - */ -export const fetchUserVaultV2Addresses = async (owner: string, network: SupportedNetworks): Promise => { - const agentConfig = getAgentConfig(network); - - if (!agentConfig?.adapterSubgraphEndpoint) { - // No subgraph configured for this network - return []; - } - - const subgraphUrl = agentConfig.adapterSubgraphEndpoint; - - try { - const variables = { - owner: owner.toLowerCase(), - }; - - const response = await subgraphGraphqlFetcher(subgraphUrl, userVaultsV2AddressesQuery, variables); - - if (response.errors) { - console.error('GraphQL errors with adapterSubgraphEndpoint:', response.errors); - return []; - } - - const vaults = response.data?.vaultV2S; - if (!vaults || vaults.length === 0) { - // No vaults found for this owner on this network - return []; - } - - // Convert to UserVaultV2Address with network information - return vaults.map((vault) => ({ - address: vault.id, - networkId: network, - })); - } catch (error) { - console.error(`Error fetching V2 vault addresses for owner ${owner} on network ${network}:`, error); - return []; - } -}; - -/** - * Fetches vault addresses from all networks that support V2 vaults - */ -export const fetchUserVaultV2AddressesAllNetworks = async (owner: string): Promise => { - const supportedNetworks = networks.filter((network) => isAgentAvailable(network.network)).map((network) => network.network); - - const promises = supportedNetworks.map(async (network) => { - return fetchUserVaultV2Addresses(owner, network); - }); - - const results = await Promise.all(promises); - return results.flat(); -}; diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index e7df3d62..6af3d9ee 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -18,7 +18,6 @@ import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; import { v2AgentsBase } from '@/utils/monarch-agent'; import { getMorphoAddress } from '@/utils/morpho'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; -import { useVaultKeysCache } from '@/stores/useVaultKeysCache'; import { useVaultInitializationModalStore } from '@/stores/vault-initialization-modal-store'; const ZERO_ADDRESS = zeroAddress; @@ -209,9 +208,6 @@ export function VaultInitializationModal() { return SupportedNetworks.Base; }, [chainIdParam]); - // Cache for pushing known keys after init (instant RPC data on next refetch) - const { addAllocators, addAdapters } = useVaultKeysCache(vaultAddress, chainId); - // Fetch vault data const vaultDataQuery = useVaultV2Data({ vaultAddress: vaultAddressValue, @@ -254,7 +250,7 @@ export function VaultInitializationModal() { return (configured as Address | undefined) ?? ZERO_ADDRESS; }, [chainId]); - // Adapter is detected if it exists in the subgraph OR we just deployed it + // Adapter is detected if Monarch has indexed it or we just deployed it locally. const adapterAddress = deployedAdapter !== ZERO_ADDRESS ? deployedAdapter : (marketAdapter ?? ZERO_ADDRESS); const adapterDetected = adapterAddress !== ZERO_ADDRESS; @@ -303,7 +299,7 @@ export function VaultInitializationModal() { const adapter = (decoded.args as any).morphoMarketV1Adapter as Address; setDeployedAdapter(adapter); - // Trigger refetch for subgraph sync + // Trigger refetch so the Monarch-backed vault query can pick up the new adapter. void refetchAdapter(); // Auto-advance to next step @@ -331,22 +327,7 @@ export function VaultInitializationModal() { return; } - // Push known keys to cache so RPC fetches them instantly on next refetch - const allocatorsToCache: string[] = []; - if (connectedAccount) { - allocatorsToCache.push(connectedAccount); - } - if (selectedAgent) { - allocatorsToCache.push(selectedAgent); - } - if (allocatorsToCache.length > 0) { - addAllocators(allocatorsToCache); - } - if (adapterAddress !== ZERO_ADDRESS) { - addAdapters([adapterAddress]); - } - - // Trigger refetch — cache keys are now available, RPC will return fresh data + // Trigger refetch after initialization completes. void vaultDataQuery.refetch(); void vaultContract.refetch(); void refetchAdapter(); @@ -361,8 +342,6 @@ export function VaultInitializationModal() { vaultContract, refetchAdapter, close, - addAllocators, - addAdapters, connectedAccount, registryAddress, selectedAgent, @@ -385,7 +364,7 @@ export function VaultInitializationModal() { } }, [isOpen]); - // Auto-advance when adapter already exists (from subgraph) + // Auto-advance when adapter already exists in Monarch data. useEffect(() => { if (marketAdapter !== ZERO_ADDRESS && stepIndex === 0 && deployedAdapter === ZERO_ADDRESS) { setStepIndex(1); diff --git a/src/features/autovault/components/vault-detail/settings/EditCaps.tsx b/src/features/autovault/components/vault-detail/settings/EditCaps.tsx index 55bb79dc..3263d33a 100644 --- a/src/features/autovault/components/vault-detail/settings/EditCaps.tsx +++ b/src/features/autovault/components/vault-detail/settings/EditCaps.tsx @@ -5,7 +5,7 @@ import { type Address, parseUnits, maxUint128 } from 'viem'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; -import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import type { VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import { useMarketNetwork } from '@/hooks/useMarketNetwork'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; import type { CapData } from '@/hooks/useVaultV2Data'; diff --git a/src/features/autovault/vault-list-view.tsx b/src/features/autovault/vault-list-view.tsx index 253c2959..2314324a 100644 --- a/src/features/autovault/vault-list-view.tsx +++ b/src/features/autovault/vault-list-view.tsx @@ -12,7 +12,7 @@ import { Button } from '@/components/ui/button'; import { Avatar } from '@/components/Avatar/Avatar'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import Header from '@/components/layout/header/Header'; -import { fetchUserVaultV2AddressesAllNetworks } from '@/data-sources/subgraph/v2-vaults'; +import { fetchUserVaultV2AddressesAllNetworks } from '@/data-sources/monarch-api/vaults'; import { getDeployedVaults } from '@/utils/vault-storage'; import { DeploymentModal } from './components/deployment/deployment-modal'; import { SectionTag, FeatureCard } from '@/components/landing'; @@ -59,7 +59,7 @@ export default function AutovaultListContent() { setHasMounted(true); }, []); - // Fetch vault addresses from subgraph (simple, fast, works for uninitialized vaults) + // Fetch vault addresses from Monarch API and merge with local optimistic vaults below. useEffect(() => { if (!address || !isConnected) { setVaultAddresses([]); diff --git a/src/features/positions/components/user-vaults-table.tsx b/src/features/positions/components/user-vaults-table.tsx index d22d0e0c..f0816457 100644 --- a/src/features/positions/components/user-vaults-table.tsx +++ b/src/features/positions/components/user-vaults-table.tsx @@ -10,7 +10,7 @@ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@ import { TokenIcon } from '@/components/shared/token-icon'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { TableContainerWithHeader } from '@/components/common/table-container-with-header'; -import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { UserVaultV2 } from '@/data-sources/monarch-api/vaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { useAppSettings } from '@/stores/useAppSettings'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; diff --git a/src/features/positions/components/vault-allocation-detail.tsx b/src/features/positions/components/vault-allocation-detail.tsx index e78a10ea..0cc4bd78 100644 --- a/src/features/positions/components/vault-allocation-detail.tsx +++ b/src/features/positions/components/vault-allocation-detail.tsx @@ -7,7 +7,7 @@ import { Spinner } from '@/components/ui/spinner'; import { MarketIdentity, MarketIdentityFocus, MarketIdentityMode } from '@/features/markets/components/market-identity'; import { MarketRiskIndicators } from '@/features/markets/components/market-risk-indicators'; import { APYCell } from '@/features/markets/components/apy-breakdown-tooltip'; -import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { UserVaultV2 } from '@/data-sources/monarch-api/vaults'; import { useRateLabel } from '@/hooks/useRateLabel'; import { useVaultAllocations } from '@/hooks/useVaultAllocations'; import { formatBalance } from '@/utils/balance'; diff --git a/src/graphql/morpho-market-v1-adapter-queries.ts b/src/graphql/morpho-market-v1-adapter-queries.ts deleted file mode 100644 index cf2713cb..00000000 --- a/src/graphql/morpho-market-v1-adapter-queries.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const morphoMarketV1AdaptersQuery = ` - query CreateMorphoMarketV1Adapters($parentVault: String!, $morpho: String!) { - createMorphoMarketV1Adapters(where: { parentVault: $parentVault, morpho: $morpho }) { - id - parentVault - morpho - morphoMarketV1Adapter - } - } -`; diff --git a/src/graphql/morpho-v2-subgraph-queries.ts b/src/graphql/morpho-v2-subgraph-queries.ts deleted file mode 100644 index 7d2b4f72..00000000 --- a/src/graphql/morpho-v2-subgraph-queries.ts +++ /dev/null @@ -1,15 +0,0 @@ -// GraphQL queries for V2 Vault Factory subgraph - -// Query to fetch only vault addresses by owner -// Ordered by createdAtBlockNumber descending to ensure newest vaults come first -export const userVaultsV2AddressesQuery = ` - query UserVaultsV2Addresses($owner: String!) { - vaultV2S( - where: { - owner: $owner - } - ) { - id - } - } -`; diff --git a/src/graphql/vault-queries.ts b/src/graphql/vault-queries.ts index b9ee688e..084c99a4 100644 --- a/src/graphql/vault-queries.ts +++ b/src/graphql/vault-queries.ts @@ -11,6 +11,7 @@ export const allVaultsQuery = ` id } name + avgApy state { totalAssets } @@ -22,3 +23,17 @@ export const allVaultsQuery = ` } } `; + +export const vaultApysQuery = ` + query VaultApys($first: Int, $where: VaultFilters) { + vaults(first: $first, where: $where) { + items { + address + avgApy + chain { + id + } + } + } + } +`; diff --git a/src/hooks/queries/useAllocations.ts b/src/hooks/queries/useAllocations.ts index 834f674d..36e3af33 100644 --- a/src/hooks/queries/useAllocations.ts +++ b/src/hooks/queries/useAllocations.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import type { Address } from 'viem'; import { useQuery } from '@tanstack/react-query'; import { vaultv2Abi } from '@/abis/vaultv2'; -import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import type { VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; diff --git a/src/hooks/queries/useUserVaultsV2Query.ts b/src/hooks/queries/useUserVaultsV2Query.ts index f6fe1c7a..03bddcd4 100644 --- a/src/hooks/queries/useUserVaultsV2Query.ts +++ b/src/hooks/queries/useUserVaultsV2Query.ts @@ -1,11 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import type { Address } from 'viem'; import { useConnection } from 'wagmi'; -import { fetchMorphoMarketV1Adapters } from '@/data-sources/subgraph/morpho-market-v1-adapters'; -import { fetchMultipleVaultV2DetailsAcrossNetworks } from '@/data-sources/morpho-api/v2-vaults'; -import { fetchUserVaultV2AddressesAllNetworks, type UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; -import { getMorphoAddress } from '@/utils/morpho'; -import { getNetworkConfig } from '@/utils/networks'; +import { fetchMorphoVaultApys } from '@/data-sources/morpho-api/vaults'; +import { fetchUserVaultV2DetailsAllNetworks, type UserVaultV2 } from '@/data-sources/monarch-api/vaults'; import { fetchUserVaultShares } from '@/utils/vaultAllocation'; type UseUserVaultsV2Options = { @@ -18,76 +15,40 @@ function filterValidVaults(vaults: UserVaultV2[]): UserVaultV2[] { } async function fetchAndProcessVaults(userAddress: Address): Promise { - // Step 1: Fetch vault addresses from subgraph across all networks - const vaultAddresses = await fetchUserVaultV2AddressesAllNetworks(userAddress); - - if (vaultAddresses.length === 0) { - return []; - } - - // Step 2: Fetch full vault details from Morpho API - const vaultDetails = await fetchMultipleVaultV2DetailsAcrossNetworks(vaultAddresses); - - // Step 3: Filter valid vaults - const validVaults = filterValidVaults(vaultDetails as UserVaultV2[]); + const validVaults = filterValidVaults(await fetchUserVaultV2DetailsAllNetworks(userAddress)); if (validVaults.length === 0) { return []; } - // Step 4: Batch fetch adapters from subgraph for each vault - const adapterPromises = validVaults.map(async (vault) => { - const networkConfig = getNetworkConfig(vault.networkId); - const subgraphUrl = networkConfig?.vaultConfig?.adapterSubgraphEndpoint; - - if (!subgraphUrl) { - return { vaultAddress: vault.address, adapter: undefined }; - } - - try { - const morphoAddress = getMorphoAddress(vault.networkId); - const adapters = await fetchMorphoMarketV1Adapters({ - subgraphUrl, - parentVault: vault.address as Address, - morpho: morphoAddress as Address, - }); - - return { - vaultAddress: vault.address, - adapter: adapters.length > 0 ? adapters[0].adapter : undefined, - }; - } catch (error) { - console.error(`Failed to fetch adapter for vault ${vault.address}:`, error); - return { vaultAddress: vault.address, adapter: undefined }; - } - }); - - const adapterResults = await Promise.all(adapterPromises); - const adapterMap = new Map(adapterResults.map((r) => [r.vaultAddress.toLowerCase(), r.adapter])); + const avgApyByVault = await fetchMorphoVaultApys( + validVaults.map((vault) => ({ + address: vault.address, + networkId: vault.networkId, + })), + ); - // Step 5: Batch fetch user's share balances via multicall + // Step 2: Batch fetch user's share balances via multicall const shareBalances = await fetchUserVaultShares( validVaults.map((v) => ({ address: v.address as Address, networkId: v.networkId })), userAddress, ); - // Step 6: Combine all data - const vaultsWithBalancesAndAdapters = validVaults.map((vault) => ({ + // Step 3: Combine Monarch vault metadata with balances and supplemental APY + return validVaults.map((vault) => ({ ...vault, - adapter: adapterMap.get(vault.address.toLowerCase()), + adapter: vault.adapters[0] as Address | undefined, + avgApy: avgApyByVault.get(`${vault.address.toLowerCase()}-${vault.networkId}`), balance: shareBalances.get(vault.address.toLowerCase()) ?? 0n, })); - - return vaultsWithBalancesAndAdapters; } /** * Fetches user's V2 vaults using React Query. * * Data fetching strategy: - * - Fetches vault addresses from subgraph across all networks - * - Enriches with vault details from Morpho API - * - Fetches adapter info from subgraph + * - Fetches cross-chain vault details from Monarch API + * - Optionally enriches current APY from batched Morpho API vault rates * - Fetches user's share balances via multicall * - Returns complete vault data with balances * diff --git a/src/hooks/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts index 27f77309..0cf87469 100644 --- a/src/hooks/useMorphoMarketV1Adapters.ts +++ b/src/hooks/useMorphoMarketV1Adapters.ts @@ -1,50 +1,30 @@ import { useMemo } from 'react'; import { type Address, zeroAddress } from 'viem'; -import { useQuery } from '@tanstack/react-query'; -import { fetchMorphoMarketV1Adapters } from '@/data-sources/subgraph/morpho-market-v1-adapters'; -import { getMorphoAddress } from '@/utils/morpho'; -import { getNetworkConfig, type SupportedNetworks } from '@/utils/networks'; +import { useVaultV2Data } from './useVaultV2Data'; +import type { SupportedNetworks } from '@/utils/networks'; export function useMorphoMarketV1Adapters({ vaultAddress, chainId }: { vaultAddress?: Address; chainId: SupportedNetworks }) { - const vaultConfig = useMemo(() => { - try { - return getNetworkConfig(chainId).vaultConfig; - } catch (_err) { - return undefined; - } - }, [chainId]); + const query = useVaultV2Data({ vaultAddress, chainId }); - const subgraphUrl = vaultConfig?.adapterSubgraphEndpoint ?? null; - const morpho = useMemo(() => getMorphoAddress(chainId), [chainId]); + const adapters = useMemo( + () => + (query.data?.adapters ?? []).map((adapterAddress) => ({ + adapter: adapterAddress as Address, + id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, + parentVault: (vaultAddress ?? zeroAddress) as Address, + })), + [chainId, query.data?.adapters, vaultAddress], + ); - const query = useQuery({ - queryKey: ['morpho-market-v1-adapters', vaultAddress, chainId], - queryFn: async () => { - if (!vaultAddress || !subgraphUrl) { - return []; - } - - const result = await fetchMorphoMarketV1Adapters({ - subgraphUrl, - parentVault: vaultAddress, - morpho, - }); - - return result; - }, - enabled: Boolean(vaultAddress && subgraphUrl), - staleTime: 30_000, // 30 seconds - adapter data is cacheable - }); - - const morphoMarketV1Adapter = useMemo(() => (query.data && query.data.length > 0 ? query.data[0].adapter : zeroAddress), [query.data]); + const morphoMarketV1Adapter = useMemo(() => (adapters.length > 0 ? adapters[0].adapter : zeroAddress), [adapters]); return { morphoMarketV1Adapter, - adapters: query.data ?? [], // all market adapters (should only be just one) + adapters, isLoading: query.isLoading, error: query.error, refetch: query.refetch, isRefetching: query.isRefetching, - hasAdapters: (query.data ?? []).length > 0, + hasAdapters: adapters.length > 0, }; } diff --git a/src/hooks/usePortfolioValue.ts b/src/hooks/usePortfolioValue.ts index dd3bceed..8c59f249 100644 --- a/src/hooks/usePortfolioValue.ts +++ b/src/hooks/usePortfolioValue.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; -import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { UserVaultV2 } from '@/data-sources/monarch-api/vaults'; import type { MarketPositionWithEarnings } from '@/utils/types'; import { type AssetBreakdownItem, diff --git a/src/hooks/useVaultAllocations.ts b/src/hooks/useVaultAllocations.ts index 79851c24..d20a226a 100644 --- a/src/hooks/useVaultAllocations.ts +++ b/src/hooks/useVaultAllocations.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import type { Address } from 'viem'; import type { CollateralAllocation, MarketAllocation } from '@/types/vaultAllocations'; -import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import type { VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import { parseCapIdParams } from '@/utils/morpho'; import type { SupportedNetworks } from '@/utils/networks'; import { findToken } from '@/utils/tokens'; diff --git a/src/hooks/useVaultHistoricalApy.ts b/src/hooks/useVaultHistoricalApy.ts index d440da13..042de0ef 100644 --- a/src/hooks/useVaultHistoricalApy.ts +++ b/src/hooks/useVaultHistoricalApy.ts @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import type { Address } from 'viem'; import { vaultv2Abi } from '@/abis/vaultv2'; import { useCustomRpcContext } from '@/components/providers/CustomRpcProvider'; -import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { UserVaultV2 } from '@/data-sources/monarch-api/vaults'; import type { EarningsPeriod } from '@/stores/usePositionsFilters'; import { estimateBlockAtTimestamp } from '@/utils/blockEstimation'; import type { SupportedNetworks } from '@/utils/networks'; diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 9e042191..9a682779 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -3,13 +3,12 @@ import { type Address, encodeFunctionData, zeroAddress, toFunctionSelector } fro import { useQueryClient } from '@tanstack/react-query'; import { useConnection, useChainId, useReadContracts } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; -import type { VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; +import type { VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import type { SupportedNetworks } from '@/utils/networks'; import { useTransactionWithToast } from './useTransactionWithToast'; import type { Market } from '@/utils/types'; import { encodeMarketParams } from '@/utils/morpho'; import { findAgent } from '@/utils/monarch-agent'; -import { useVaultKeysCache } from '@/stores/useVaultKeysCache'; export type PerformanceFeeConfig = { fee: bigint; @@ -74,7 +73,6 @@ export function useVaultV2({ const chainIdToUse = (chainId ?? connectedChainId) as SupportedNetworks; const { address: account } = useConnection(); const queryClient = useQueryClient(); - const { addAllocators: cacheAllocators, addCaps: cacheCaps } = useVaultKeysCache(vaultAddress, chainIdToUse); const vaultContract = { address: vaultAddress ?? zeroAddress, @@ -476,10 +474,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - // Push to cache so RPC picks it up instantly on next refetch - if (isAllocator) { - cacheAllocators([allocator]); - } void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); return true; @@ -491,7 +485,7 @@ export function useVaultV2({ throw allocatorError; } }, - [account, chainIdToUse, sendAllocatorTx, vaultAddress, cacheAllocators, queryClient], + [account, chainIdToUse, sendAllocatorTx, vaultAddress, queryClient], ); const swapAllocator = useCallback( @@ -549,8 +543,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - // Push new allocator to cache - cacheAllocators([newAllocator]); void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); return true; @@ -562,7 +554,7 @@ export function useVaultV2({ throw swapError; } }, - [account, chainIdToUse, sendSwapAllocatorTx, vaultAddress, cacheAllocators, queryClient], + [account, chainIdToUse, sendSwapAllocatorTx, vaultAddress, queryClient], ); const updateCaps = useCallback( @@ -664,8 +656,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - // Push cap keys to cache so RPC picks them up instantly on next refetch - cacheCaps(caps.map((cap) => ({ capId: cap.capId, idParams: cap.idParams }))); void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); void queryClient.invalidateQueries({ queryKey: ['vault-allocations', vaultAddress, chainIdToUse] }); @@ -678,7 +668,7 @@ export function useVaultV2({ throw capsError; } }, - [account, chainIdToUse, sendCapsTx, vaultAddress, cacheCaps], + [account, chainIdToUse, sendCapsTx, vaultAddress, queryClient], ); const { isConfirming: isDepositing, sendTransactionAsync: sendDepositTx } = useTransactionWithToast({ diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 9fc545f6..c0960c35 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -2,13 +2,12 @@ import type { Address } from 'viem'; import { zeroAddress } from 'viem'; import { useQuery } from '@tanstack/react-query'; import { vaultv2Abi } from '@/abis/vaultv2'; +import { fetchMonarchVaultDetails, type VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; -import { fetchVaultV2Details, type VaultV2Cap } from '@/data-sources/morpho-api/v2-vaults'; import { getSlicedAddress } from '@/utils/address'; import { parseCapIdParams } from '@/utils/morpho'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; -import { useVaultKeysCache, type CachedCap, combineAddresses, combineCaps } from '@/stores/useVaultKeysCache'; type UseVaultV2DataArgs = { vaultAddress?: Address; @@ -28,8 +27,8 @@ export type VaultV2Data = { displayName: string; displaySymbol: string; assetAddress: string; - tokenSymbol: string; // Always has default: '--' - tokenDecimals: number; // Always has default: 18 + tokenSymbol: string; + tokenDecimals: number; allocators: string[]; sentinels: string[]; owner: string; @@ -39,9 +38,68 @@ export type VaultV2Data = { curatorDisplay: string; }; +type BasicVaultRpcData = { + assetAddress: string; + adapters: string[]; + curator: string; + displayName: string; + displaySymbol: string; + owner: string; +}; + +const fetchBasicVaultRpcData = async (vaultAddress: Address, chainId: SupportedNetworks): Promise => { + const client = getClient(chainId); + const contractBase = { address: vaultAddress, abi: vaultv2Abi } as const; + const [owner, curator, name, symbol, asset] = await client.multicall({ + contracts: [ + { ...contractBase, functionName: 'owner', args: [] }, + { ...contractBase, functionName: 'curator', args: [] }, + { ...contractBase, functionName: 'name', args: [] }, + { ...contractBase, functionName: 'symbol', args: [] }, + { ...contractBase, functionName: 'asset', args: [] }, + ], + allowFailure: true, + }); + + const adaptersLength = await client + .readContract({ + ...contractBase, + functionName: 'adaptersLength', + args: [], + }) + .catch(() => 0n); + + let adapters: string[] = []; + if (adaptersLength > 0n) { + const adapterResults = await client.multicall({ + contracts: Array.from({ length: Number(adaptersLength) }, (_, index) => ({ + ...contractBase, + functionName: 'adapters' as const, + args: [BigInt(index)], + })), + allowFailure: true, + }); + + adapters = adapterResults.flatMap((result) => { + if (result.status !== 'success' || result.result === zeroAddress) { + return []; + } + return [result.result.toLowerCase()]; + }); + } + + return { + adapters, + owner: owner.status === 'success' && owner.result !== zeroAddress ? owner.result : '', + curator: curator.status === 'success' && curator.result !== zeroAddress ? curator.result : '', + displayName: name.status === 'success' ? name.result : '', + displaySymbol: symbol.status === 'success' ? symbol.result : '', + assetAddress: asset.status === 'success' && asset.result !== zeroAddress ? asset.result : '', + }; +}; + export function useVaultV2Data({ vaultAddress, chainId, fallbackName = '', fallbackSymbol = '' }: UseVaultV2DataArgs) { const { findToken } = useTokensQuery(); - const { getVaultKeys, seedFromApi } = useVaultKeysCache(vaultAddress, chainId); const query = useQuery({ queryKey: ['vault-v2-data', vaultAddress, chainId], @@ -50,178 +108,70 @@ export function useVaultV2Data({ vaultAddress, chainId, fallbackName = '', fallb return null; } - // --- Stage 1: Discovery (API seed + cache merge) --- - - // Try Morpho API — gracefully handle errors (e.g. new vault not indexed yet) - let apiResult = null; + let monarchVault = null; try { - apiResult = await fetchVaultV2Details(vaultAddress, chainId); - } catch (apiError) { - console.warn('[useVaultV2Data] API fetch failed, continuing with cache + RPC:', apiError); + monarchVault = await fetchMonarchVaultDetails(vaultAddress, chainId); + } catch (monarchError) { + console.warn('[useVaultV2Data] Monarch vault fetch failed, continuing with RPC fallback:', monarchError); } - // Read cached keys - const cachedKeys = getVaultKeys(); - - // Merge API + cache keys with deduplication - const allocatorAddresses = combineAddresses(apiResult?.allocators, cachedKeys.allocators); - const apiCaps: CachedCap[] = apiResult?.caps.map((c) => ({ capId: c.capId, idParams: c.idParams })) ?? []; - const capEntries = combineCaps(apiCaps, cachedKeys.caps); - const adapterAddresses = combineAddresses(apiResult?.adapters, cachedKeys.adapters); - - // Seed cache with API-discovered keys (deduplication handled by store) - if (apiResult) { - seedFromApi({ - allocators: apiResult.allocators, - caps: apiResult.caps.map((c) => ({ capId: c.capId, idParams: c.idParams })), - adapters: apiResult.adapters, - }); - } - - // --- Stage 2: RPC truth (single multicall) --- - - const client = getClient(chainId); - const contractBase = { address: vaultAddress, abi: vaultv2Abi } as const; - - // Build multicall contracts array with known layout - const basicContracts = [ - // Basic vault data (always read from RPC) - { ...contractBase, functionName: 'owner' as const, args: [] }, - { ...contractBase, functionName: 'curator' as const, args: [] }, - { ...contractBase, functionName: 'name' as const, args: [] }, - { ...contractBase, functionName: 'symbol' as const, args: [] }, - { ...contractBase, functionName: 'asset' as const, args: [] }, - ]; - - const allocatorContracts = allocatorAddresses.map((addr) => ({ - ...contractBase, - functionName: 'isAllocator' as const, - args: [addr as Address], - })); - - const capContracts = capEntries.flatMap((cap) => [ - { ...contractBase, functionName: 'relativeCap' as const, args: [cap.capId as `0x${string}`] }, - { ...contractBase, functionName: 'absoluteCap' as const, args: [cap.capId as `0x${string}`] }, - ]); - - const adapterContracts = adapterAddresses.map((addr) => ({ - ...contractBase, - functionName: 'isAdapter' as const, - args: [addr as Address], - })); - - const contracts = [...basicContracts, ...allocatorContracts, ...capContracts, ...adapterContracts]; - - const results = await client.multicall({ - contracts, - allowFailure: true, - }); - - // --- Process results --- - - // Offsets - const allocatorOffset = basicContracts.length; - const capsOffset = allocatorOffset + allocatorContracts.length; - const adapterOffset = capsOffset + capContracts.length; - - // Basic fields - const rpcOwner = (results[0].status === 'success' ? results[0].result : zeroAddress) as Address; - const rpcCurator = (results[1].status === 'success' ? results[1].result : zeroAddress) as Address; - const rpcName = (results[2].status === 'success' ? results[2].result : '') as string; - const rpcSymbol = (results[3].status === 'success' ? results[3].result : '') as string; - const rpcAsset = (results[4].status === 'success' ? results[4].result : zeroAddress) as Address; - - // Filter active allocators - const activeAllocators: string[] = []; - for (let i = 0; i < allocatorAddresses.length; i++) { - const r = results[allocatorOffset + i]; - if (r.status === 'success' && r.result === true) { - activeAllocators.push(allocatorAddresses[i]); + let rpcFallback: BasicVaultRpcData | null = null; + if (!monarchVault) { + try { + rpcFallback = await fetchBasicVaultRpcData(vaultAddress, chainId); + } catch (rpcError) { + console.warn('[useVaultV2Data] RPC fallback failed for vault metadata:', rpcError); } } - // Build caps with RPC values, classify by type + const caps = monarchVault?.caps ?? []; let adapterCap: VaultV2Cap | null = null; const collateralCaps: VaultV2Cap[] = []; const marketCaps: VaultV2Cap[] = []; - for (let i = 0; i < capEntries.length; i++) { - const relIdx = capsOffset + i * 2; - const absIdx = capsOffset + i * 2 + 1; - const relResult = results[relIdx]; - const absResult = results[absIdx]; - - const relativeCapValue = relResult.status === 'success' ? (relResult.result as bigint) : 0n; - const absoluteCapValue = absResult.status === 'success' ? (absResult.result as bigint) : 0n; - - // Skip caps where both values are zero (removed or unset) - if (relativeCapValue === 0n && absoluteCapValue === 0n) continue; - - const cap: VaultV2Cap = { - capId: capEntries[i].capId, - idParams: capEntries[i].idParams, - relativeCap: relativeCapValue.toString(), - absoluteCap: absoluteCapValue.toString(), - }; - + for (const cap of caps) { const parsed = parseCapIdParams(cap.idParams); if (parsed.type === 'adapter') { adapterCap = cap; - } else if (parsed.type === 'collateral') { + continue; + } + if (parsed.type === 'collateral') { collateralCaps.push(cap); - } else if (parsed.type === 'market') { - marketCaps.push(cap); + continue; } - } - - // Filter active adapters - const activeAdapters: string[] = []; - for (let i = 0; i < adapterAddresses.length; i++) { - const r = results[adapterOffset + i]; - if (r.status === 'success' && r.result === true) { - activeAdapters.push(adapterAddresses[i]); + if (parsed.type === 'market') { + marketCaps.push(cap); } } - // Resolve token metadata - const assetAddress = rpcAsset !== zeroAddress ? rpcAsset : (apiResult?.asset ?? ''); + const assetAddress = monarchVault?.asset || rpcFallback?.assetAddress || ''; const token = assetAddress ? findToken(assetAddress, chainId) : undefined; const tokenSymbol = token?.symbol ?? '--'; const tokenDecimals = token?.decimals ?? 18; + const curator = monarchVault?.curator || rpcFallback?.curator || ''; - // Curator display - const curatorAddr = rpcCurator !== zeroAddress ? rpcCurator : (apiResult?.curator ?? ''); - const curatorDisplay = curatorAddr ? getSlicedAddress(curatorAddr as Address) : '--'; - - // Sentinels come from API only (not cached, not critical for post-init flow) - const sentinels = apiResult?.sentinels ?? []; - - const needSetupCaps = !adapterCap || collateralCaps.length === 0 || marketCaps.length === 0; - - const vaultData: VaultV2Data = { - displayName: rpcName || apiResult?.name || fallbackName, - displaySymbol: rpcSymbol || apiResult?.symbol || fallbackSymbol, + return { + displayName: monarchVault?.name || rpcFallback?.displayName || fallbackName, + displaySymbol: monarchVault?.symbol || rpcFallback?.displaySymbol || fallbackSymbol, assetAddress, tokenSymbol, tokenDecimals, - allocators: activeAllocators, - sentinels, - owner: rpcOwner !== zeroAddress ? rpcOwner : (apiResult?.owner ?? ''), - curator: curatorAddr, + allocators: monarchVault?.allocators ?? [], + sentinels: monarchVault?.sentinels ?? [], + owner: monarchVault?.owner || rpcFallback?.owner || '', + curator, capsData: { adapterCap, collateralCaps, marketCaps, - needSetupCaps, + needSetupCaps: !adapterCap || collateralCaps.length === 0 || marketCaps.length === 0, }, - adapters: activeAdapters, - curatorDisplay, - }; - - return vaultData; + adapters: monarchVault?.adapters ?? rpcFallback?.adapters ?? [], + curatorDisplay: curator ? getSlicedAddress(curator as Address) : '--', + } satisfies VaultV2Data; }, enabled: Boolean(vaultAddress), - staleTime: 30_000, // 30 seconds - data is cacheable across components + staleTime: 30_000, }); return query; diff --git a/src/stores/useVaultKeysCache.ts b/src/stores/useVaultKeysCache.ts deleted file mode 100644 index 2ff44147..00000000 --- a/src/stores/useVaultKeysCache.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback } from 'react'; -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -type CachedCap = { - capId: string; - idParams: string; -}; - -type VaultKeysEntry = { - allocators: string[]; - caps: CachedCap[]; - adapters: string[]; -}; - -function emptyEntry(): VaultKeysEntry { - return { allocators: [], caps: [], adapters: [] }; -} - -function makeVaultKey(vaultAddress: string, chainId: number): string { - return `${vaultAddress.toLowerCase()}:${chainId}`; -} - -/** - * Deduplicate and merge multiple lists of addresses (case-insensitive). - * Returns a new array containing unique addresses from all sources, in order of appearance. - */ -export function combineAddresses(...sources: (string[] | undefined | null)[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const source of sources) { - if (!source) continue; - for (const addr of source) { - const lower = addr.toLowerCase(); - if (!seen.has(lower)) { - seen.add(lower); - result.push(lower); - } - } - } - return result; -} - -/** - * Deduplicate and merge multiple lists of caps (by capId). - * Returns a new array containing unique caps from all sources, in order of appearance. - */ -export function combineCaps(...sources: (CachedCap[] | undefined | null)[]): CachedCap[] { - const seen = new Set(); - const result: CachedCap[] = []; - for (const source of sources) { - if (!source) continue; - for (const cap of source) { - if (!seen.has(cap.capId)) { - seen.add(cap.capId); - result.push({ capId: cap.capId, idParams: cap.idParams }); - } - } - } - return result; -} - -/** - * Zustand store for caching vault data keys (allocators, caps, adapters). - * - * Persists the "keys" needed to query on-chain vault state via RPC. - * After transactions, push new keys here → next RPC fetch picks them up instantly. - * API data seeds this cache on first load → keys survive across sessions. - * RPC verifies each key on-chain → stale keys are filtered out automatically. - */ -export const useVaultKeysCacheStore = create<{ - cache: Record; - addAllocators: (vaultKey: string, addresses: string[]) => void; - addCaps: (vaultKey: string, caps: CachedCap[]) => void; - addAdapters: (vaultKey: string, addresses: string[]) => void; - getVaultKeys: (vaultKey: string) => VaultKeysEntry; - seedFromApi: (vaultKey: string, entry: Partial) => void; -}>()( - persist( - (set, get) => ({ - cache: {}, - - addAllocators: (vaultKey, addresses) => { - set((state) => { - const entry = state.cache[vaultKey] ?? emptyEntry(); - const merged = combineAddresses(entry.allocators, addresses); - if (merged.length === entry.allocators.length) return state; - return { cache: { ...state.cache, [vaultKey]: { ...entry, allocators: merged } } }; - }); - }, - - addCaps: (vaultKey, caps) => { - set((state) => { - const entry = state.cache[vaultKey] ?? emptyEntry(); - const merged = combineCaps(entry.caps, caps); - if (merged.length === entry.caps.length) return state; - return { cache: { ...state.cache, [vaultKey]: { ...entry, caps: merged } } }; - }); - }, - - addAdapters: (vaultKey, addresses) => { - set((state) => { - const entry = state.cache[vaultKey] ?? emptyEntry(); - const merged = combineAddresses(entry.adapters, addresses); - if (merged.length === entry.adapters.length) return state; - return { cache: { ...state.cache, [vaultKey]: { ...entry, adapters: merged } } }; - }); - }, - - getVaultKeys: (vaultKey) => get().cache[vaultKey] ?? emptyEntry(), - - seedFromApi: (vaultKey, entry) => { - const store = get(); - if (entry.allocators?.length) store.addAllocators(vaultKey, entry.allocators); - if (entry.caps?.length) store.addCaps(vaultKey, entry.caps); - if (entry.adapters?.length) store.addAdapters(vaultKey, entry.adapters); - }, - }), - { - name: 'monarch_store_vaultKeysCache', - version: 2, - migrate: () => ({ cache: {} }), - }, - ), -); - -/** - * Convenience hook scoped to a specific vault. - * - * @example - * const { addAllocators, addCaps, getVaultKeys } = useVaultKeysCache(vaultAddress, chainId); - */ -export function useVaultKeysCache(vaultAddress: string | undefined, chainId: number | undefined) { - const vaultKey = vaultAddress && chainId ? makeVaultKey(vaultAddress, chainId) : ''; - const store = useVaultKeysCacheStore(); - - return { - addAllocators: useCallback( - (addresses: string[]) => { - if (vaultKey) store.addAllocators(vaultKey, addresses); - }, - [store.addAllocators, vaultKey], - ), - addCaps: useCallback( - (caps: CachedCap[]) => { - if (vaultKey) store.addCaps(vaultKey, caps); - }, - [store.addCaps, vaultKey], - ), - addAdapters: useCallback( - (addresses: string[]) => { - if (vaultKey) store.addAdapters(vaultKey, addresses); - }, - [store.addAdapters, vaultKey], - ), - getVaultKeys: useCallback( - (): VaultKeysEntry => (vaultKey ? store.getVaultKeys(vaultKey) : emptyEntry()), - [store.getVaultKeys, vaultKey], - ), - seedFromApi: useCallback( - (entry: Partial) => { - if (vaultKey) store.seedFromApi(vaultKey, entry); - }, - [store.seedFromApi, vaultKey], - ), - vaultKey, - }; -} - -export type { CachedCap, VaultKeysEntry }; diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 67c15a3e..c9397840 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -60,10 +60,8 @@ export const hyperEvm = defineChain({ type VaultAgentConfig = { v2FactoryAddress: Address; - vaultsSubgraphEndpoint?: string; // temporary Subgraph to fetch deployed vaults for users morphoRegistry: Address; // the RegistryList contract deployed by morpho! marketV1AdapterFactory: Address; // MorphoMarketV1AdapterFactory contract used to create adapters for markets - adapterSubgraphEndpoint?: string; strategies?: AgentMetadata[]; }; @@ -108,10 +106,8 @@ export const networks: NetworkConfig[] = [ vaultConfig: { v2FactoryAddress: '0x4501125508079A99ebBebCE205DeC9593C2b5857', strategies: v2AgentsBase, - vaultsSubgraphEndpoint: 'https://api.studio.thegraph.com/query/94369/morpho-v-2-vault-factory-base/version/latest', morphoRegistry: '0x5C2531Cbd2cf112Cf687da3Cd536708aDd7DB10a', marketV1AdapterFactory: '0x133baC94306B99f6dAD85c381a5be851d8DD717c', - adapterSubgraphEndpoint: 'https://gateway.thegraph.com/api/subgraphs/id/8dNeYJ1jDzXQ7KUX43CzAjkuVrY2WgQCJZiDeMDq5EuN', }, blocktime: 2, maxBlockDelay: 5, @@ -206,7 +202,7 @@ export const isAgentAvailable = (chainId: number): boolean => { const network = getNetworkConfig(chainId); if (!network || !network.vaultConfig) return false; - return network.vaultConfig.vaultsSubgraphEndpoint !== undefined; + return true; }; export const getAgentConfig = (chainId: SupportedNetworks): VaultAgentConfig | undefined => { diff --git a/src/utils/portfolio.ts b/src/utils/portfolio.ts index 8c9a3f30..9e7d46dc 100644 --- a/src/utils/portfolio.ts +++ b/src/utils/portfolio.ts @@ -1,7 +1,7 @@ import { formatUnits } from 'viem'; import { getTokenPriceKey, type TokenPriceInput } from '@/data-sources/morpho-api/prices'; import type { MarketPositionWithEarnings } from './types'; -import type { UserVaultV2 } from '@/data-sources/subgraph/v2-vaults'; +import type { UserVaultV2 } from '@/data-sources/monarch-api/vaults'; // Normalized balance type for all position sources export type TokenBalance = { From e0e104bc966470f48d7a5c16fbb23fabda59caaf Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 19:30:33 +0800 Subject: [PATCH 2/7] refactor; refetch logic --- AGENTS.md | 2 + src/abis/morpho-market-v1-adapter-factory.ts | 3 - .../morpho-market-v1-adapter-v2-factory.ts | 11 +++ .../common/settings-modal/SettingsHeader.tsx | 22 +++--- src/data-sources/monarch-api/index.ts | 1 + src/data-sources/monarch-api/vaults.ts | 57 ++++++++++++++-- .../modals/vault-initialization-modal.tsx | 57 ++++------------ .../vault-settings/VaultSettingsContent.tsx | 2 + .../vault-settings/VaultSettingsHeader.tsx | 20 +++++- .../vault-settings/details/EditCapsDetail.tsx | 4 +- .../vault-settings/panels/RolesPanel.tsx | 21 ++++-- src/features/autovault/vault-view.tsx | 28 ++++---- ...ter.ts => useDeployMorphoMarketAdapter.ts} | 38 ++++------- src/hooks/useMorphoMarketAdapters.ts | 54 +++++++++++++++ src/hooks/useMorphoMarketV1Adapters.ts | 30 -------- src/hooks/useVaultPage.ts | 14 ++-- src/hooks/useVaultQueryRefresh.ts | 68 +++++++++++++++++++ src/hooks/useVaultV2.ts | 49 ++++++++----- src/hooks/useVaultV2Data.ts | 4 +- src/modals/vault/vault-withdraw-modal.tsx | 10 +-- src/utils/networks.ts | 4 +- 21 files changed, 335 insertions(+), 164 deletions(-) delete mode 100644 src/abis/morpho-market-v1-adapter-factory.ts create mode 100644 src/abis/morpho-market-v1-adapter-v2-factory.ts rename src/hooks/{useDeployMorphoMarketV1Adapter.ts => useDeployMorphoMarketAdapter.ts} (53%) create mode 100644 src/hooks/useMorphoMarketAdapters.ts delete mode 100644 src/hooks/useMorphoMarketV1Adapters.ts create mode 100644 src/hooks/useVaultQueryRefresh.ts diff --git a/AGENTS.md b/AGENTS.md index 6d15b0a9..abc08e9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,8 @@ When touching transaction and position flows, validation MUST include all releva 30. **Fee preview consistency**: transaction previews that show protocol/app fees must derive token/USD display from shared fee-display helpers, use compact token amounts with explicit full-value hover content, threshold tiny USD values as `< $0.01` while preserving exact USD on hover, and avoid ad hoc per-modal formatting drift. 31. **Market list first-paint integrity**: shared multi-chain market list queries must not let a single slow chain or slow fallback path block first paint indefinitely; network fetches should use bounded request timeouts that account for fallback coverage, and any fallback path used for first paint must preserve market completeness (no truncated `first: 1000` fallback). Once page 1 reveals total count, remaining pagination should be fetched in parallel or bounded parallel batches instead of strict sequential loops. 32. **Canonical vault-metadata source integrity**: when Monarch API exposes chain-scoped V2 vault metadata (owner, curator, asset, allocators, sentinels, adapters, caps), use it as the primary read chokepoint. Do not rebuild the same view through per-chain subgraphs, slow Morpho API fanout, local key caches, or event parsing except for narrow RPC fallback while a fresh write has not been indexed yet. +33. **Adapter-factory write integrity**: new market-adapter deployment must use the configured chain-scoped `MorphoMarketV1AdapterV2` factory directly, and post-tx adapter discovery should come from the V2 creation receipt/log rather than an extra factory read. Existing vault adapter type labels may come from Monarch API, but do not reintroduce V1 deployment assumptions or version-branching into the write path. +34. **Vault post-tx refresh integrity**: do not invalidate/refetch Monarch-backed vault queries before transaction confirmation. After confirmed vault writes that change Monarch-indexed fields (metadata, adapters, allocators, caps), trigger the shared vault-query refetch chokepoint with bounded follow-up retries so indexer lag does not leave the UI stale. ### REQUIRED: Regression Rule Capture diff --git a/src/abis/morpho-market-v1-adapter-factory.ts b/src/abis/morpho-market-v1-adapter-factory.ts deleted file mode 100644 index 688c2eac..00000000 --- a/src/abis/morpho-market-v1-adapter-factory.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Abi } from "viem"; - -export const adapterFactoryAbi = [{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"parentVault","type":"address"},{"indexed":true,"internalType":"address","name":"morpho","type":"address"},{"indexed":true,"internalType":"address","name":"morphoMarketV1Adapter","type":"address"}],"name":"CreateMorphoMarketV1Adapter","type":"event"},{"inputs":[{"internalType":"address","name":"parentVault","type":"address"},{"internalType":"address","name":"morpho","type":"address"}],"name":"createMorphoMarketV1Adapter","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"isMorphoMarketV1Adapter","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"parentVault","type":"address"},{"internalType":"address","name":"morpho","type":"address"}],"name":"morphoMarketV1Adapter","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"}] as const satisfies Abi; \ No newline at end of file diff --git a/src/abis/morpho-market-v1-adapter-v2-factory.ts b/src/abis/morpho-market-v1-adapter-v2-factory.ts new file mode 100644 index 00000000..3d385d15 --- /dev/null +++ b/src/abis/morpho-market-v1-adapter-v2-factory.ts @@ -0,0 +1,11 @@ +import type { Abi } from 'viem'; + +export const adapterV2FactoryAbi = [ + { + inputs: [{ internalType: 'address', name: 'parentVault', type: 'address' }], + name: 'createMorphoMarketV1AdapterV2', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const satisfies Abi; diff --git a/src/components/common/settings-modal/SettingsHeader.tsx b/src/components/common/settings-modal/SettingsHeader.tsx index dd2f998a..da739f54 100644 --- a/src/components/common/settings-modal/SettingsHeader.tsx +++ b/src/components/common/settings-modal/SettingsHeader.tsx @@ -3,13 +3,14 @@ import { ArrowLeftIcon, Cross2Icon } from '@radix-ui/react-icons'; type SettingsHeaderProps = { + actions?: React.ReactNode; title: string; showBack?: boolean; onBack?: () => void; onClose: () => void; }; -export function SettingsHeader({ title, showBack, onBack, onClose }: SettingsHeaderProps) { +export function SettingsHeader({ actions, title, showBack, onBack, onClose }: SettingsHeaderProps) { return (
@@ -25,14 +26,17 @@ export function SettingsHeader({ title, showBack, onBack, onClose }: SettingsHea )}

{title}

- +
+ {actions} + +
); } diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index a7623bf8..3b821a4e 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -3,6 +3,7 @@ export { fetchMonarchVaultDetails, fetchUserVaultV2AddressesAllNetworks, fetchUserVaultV2DetailsAllNetworks, + type VaultAdapterDetails, type UserVaultV2, type UserVaultV2Address, type VaultV2Cap, diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts index b5f201a8..334a9556 100644 --- a/src/data-sources/monarch-api/vaults.ts +++ b/src/data-sources/monarch-api/vaults.ts @@ -11,6 +11,12 @@ export type VaultV2Cap = { oldAbsoluteCap?: string; }; +export type VaultAdapterDetails = { + adapterType: string; + address: string; + factoryAddress: string; +}; + export type VaultV2Details = { id: string; address: string; @@ -23,6 +29,7 @@ export type VaultV2Details = { sentinels: string[]; caps: VaultV2Cap[]; adapters: string[]; + adapterDetails: VaultAdapterDetails[]; avgApy?: number; }; @@ -76,12 +83,27 @@ type MonarchVault = { caps: MonarchVaultCap[]; }; +type MonarchAdapterRecord = { + adapterAddress: string; + adapterType: string; + chainId: number; + factoryAddress: string; + vaultAddress: string; +}; + type MonarchVaultsResponse = { data?: { Vault?: MonarchVault[]; }; }; +type MonarchVaultDetailResponse = { + data?: { + Adapter?: MonarchAdapterRecord[]; + Vault?: MonarchVault[]; + }; +}; + type MonarchVaultAddressRecord = Pick; type MonarchVaultAddressesResponse = { @@ -140,7 +162,15 @@ const transformCap = (cap: MonarchVaultCap): VaultV2Cap => { }; }; -const transformVault = (vault: MonarchVault): UserVaultV2 | null => { +const transformAdapterRecord = (adapter: MonarchAdapterRecord): VaultAdapterDetails => { + return { + address: normalizeAddress(adapter.adapterAddress), + adapterType: adapter.adapterType, + factoryAddress: normalizeAddress(adapter.factoryAddress), + }; +}; + +const transformVault = (vault: MonarchVault, adapterDetails: VaultAdapterDetails[] = []): UserVaultV2 | null => { const networkId = toSupportedNetwork(vault.chainId); if (!networkId) { return null; @@ -162,6 +192,7 @@ const transformVault = (vault: MonarchVault): UserVaultV2 | null => { sentinels: activeSentinels, caps: vault.caps.map(transformCap), adapters: activeAdapters, + adapterDetails, adapter: activeAdapters[0] as Address | undefined, networkId, }; @@ -200,6 +231,13 @@ const vaultByAddressQuery = ` Vault(where: { vaultAddress: { _eq: $address }, chainId: { _eq: $chainId } }, limit: 1) { ${MONARCH_VAULT_FIELDS} } + Adapter(where: { vaultAddress: { _eq: $address }, chainId: { _eq: $chainId } }, order_by: [{ createdAt: desc }]) { + adapterAddress + adapterType + chainId + factoryAddress + vaultAddress + } } `; @@ -209,7 +247,7 @@ export const fetchUserVaultV2DetailsAllNetworks = async (owner: string): Promise }); const vaults = response.data?.Vault ?? []; - return vaults.map(transformVault).filter((vault): vault is UserVaultV2 => vault !== null); + return vaults.map((vault) => transformVault(vault)).filter((vault): vault is UserVaultV2 => vault !== null); }; export const fetchUserVaultV2AddressesAllNetworks = async (owner: string): Promise => { @@ -222,11 +260,22 @@ export const fetchUserVaultV2AddressesAllNetworks = async (owner: string): Promi }; export const fetchMonarchVaultDetails = async (vaultAddress: string, chainId: SupportedNetworks): Promise => { - const response = await monarchGraphqlFetcher(vaultByAddressQuery, { + const response = await monarchGraphqlFetcher(vaultByAddressQuery, { address: vaultAddress.toLowerCase(), chainId, }); const vault = response.data?.Vault?.[0]; - return vault ? transformVault(vault) : null; + if (!vault) { + return null; + } + + const activeAdapterAddresses = new Set( + vault.adapters.filter((adapter) => adapter.isActive).map((adapter) => normalizeAddress(adapter.adapterAddress)), + ); + const adapterDetails = (response.data?.Adapter ?? []) + .map(transformAdapterRecord) + .filter((adapter) => activeAdapterAddresses.size === 0 || activeAdapterAddresses.has(adapter.address)); + + return transformVault(vault, adapterDetails); }; diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index 6af3d9ee..8ed74a67 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -2,25 +2,24 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { FiZap } from 'react-icons/fi'; -import { type Address, zeroAddress, decodeEventLog } from 'viem'; +import { type Address, zeroAddress } from 'viem'; import { useParams } from 'next/navigation'; -import { useConnection, usePublicClient } from 'wagmi'; +import { usePublicClient } from 'wagmi'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { AllocatorCard } from '@/components/shared/allocator-card'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; import { Spinner } from '@/components/ui/spinner'; -import { adapterFactoryAbi } from '@/abis/morpho-market-v1-adapter-factory'; -import { useDeployMorphoMarketV1Adapter } from '@/hooks/useDeployMorphoMarketV1Adapter'; +import { useDeployMorphoMarketAdapter } from '@/hooks/useDeployMorphoMarketAdapter'; +import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultV2 } from '@/hooks/useVaultV2'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; import { v2AgentsBase } from '@/utils/monarch-agent'; -import { getMorphoAddress } from '@/utils/morpho'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { useVaultInitializationModalStore } from '@/stores/vault-initialization-modal-store'; const ZERO_ADDRESS = zeroAddress; +const MORPHO_MARKET_ADAPTER_V2_CREATED_TOPIC = '0x2d5aa62fff752ff7caa68d3c82c1ae04ccb2053bd3be0ffee086953f6adc894e'; const shortenAddress = (value: Address | string) => (value === ZERO_ADDRESS ? '0x0000…0000' : `${value.slice(0, 6)}…${value.slice(-4)}`); const STEP_SEQUENCE = ['deploy', 'metadata', 'agents', 'finalize'] as const; @@ -190,7 +189,6 @@ const MAX_SYMBOL_LENGTH = 16; export function VaultInitializationModal() { // Modal state from Zustand (UI state) const { isOpen, close } = useVaultInitializationModalStore(); - const { address: connectedAccount } = useConnection(); // Get vault address and chain ID from URL params const { chainId: chainIdParam, vaultAddress } = useParams<{ @@ -229,7 +227,7 @@ export function VaultInitializationModal() { const { completeInitialization, isInitializing } = vaultContract; // Fetch adapter - const { morphoMarketV1Adapter: marketAdapter, refetch: refetchAdapter } = useMorphoMarketV1Adapters({ + const { primaryAdapter: marketAdapter, refetch: refetchAdapter } = useMorphoMarketAdapters({ vaultAddress: vaultAddressValue, chainId, }); @@ -242,8 +240,6 @@ export function VaultInitializationModal() { const currentStep = STEP_SEQUENCE[stepIndex]; const publicClient = usePublicClient({ chainId }); - - const morphoAddress = useMemo(() => (chainId ? getMorphoAddress(chainId) : ZERO_ADDRESS), [chainId]); const registryAddress = useMemo(() => { if (!chainId) return ZERO_ADDRESS; const configured = getNetworkConfig(chainId).vaultConfig?.morphoRegistry; @@ -254,61 +250,39 @@ export function VaultInitializationModal() { const adapterAddress = deployedAdapter !== ZERO_ADDRESS ? deployedAdapter : (marketAdapter ?? ZERO_ADDRESS); const adapterDetected = adapterAddress !== ZERO_ADDRESS; - const { deploy, isDeploying, canDeploy } = useDeployMorphoMarketV1Adapter({ + const { deploy, isDeploying, canDeploy, factoryAddress } = useDeployMorphoMarketAdapter({ vaultAddress: vaultAddressValue, chainId, - morphoAddress, }); const handleDeploy = useCallback(async () => { - if (!publicClient) return; + if (!publicClient || !factoryAddress) return; try { - // Execute deployment and get transaction hash const txHash = await deploy(); - if (!txHash) { return; } - // Wait for transaction receipt const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); - // Parse CreateMorphoMarketV1Adapter event to get adapter address const createEvent = receipt.logs.find((log) => { - try { - const decoded = decodeEventLog({ - abi: adapterFactoryAbi, - data: log.data, - topics: log.topics, - }); - return decoded.eventName === 'CreateMorphoMarketV1Adapter'; - } catch { + if (log.address.toLowerCase() !== factoryAddress.toLowerCase()) { return false; } + return log.topics[0] === MORPHO_MARKET_ADAPTER_V2_CREATED_TOPIC && Boolean(log.topics[2]); }); - if (createEvent) { - const decoded = decodeEventLog({ - abi: adapterFactoryAbi, - data: createEvent.data, - topics: createEvent.topics, - }); - - // Extract adapter address from event - const adapter = (decoded.args as any).morphoMarketV1Adapter as Address; - setDeployedAdapter(adapter); - - // Trigger refetch so the Monarch-backed vault query can pick up the new adapter. + if (createEvent && createEvent.topics[2]) { + const adapter = `0x${createEvent.topics[2].slice(-40)}` as Address; + setDeployedAdapter(adapter.toLowerCase() as Address); void refetchAdapter(); - - // Auto-advance to next step setStepIndex(1); } } catch (_error) { - // Error is handled by useDeployMorphoMarketV1Adapter hook + // Error is handled by useDeployMorphoMarketAdapter hook } - }, [deploy, publicClient, refetchAdapter]); + }, [deploy, factoryAddress, publicClient, refetchAdapter]); const handleCompleteInitialization = useCallback(async () => { if (adapterAddress === ZERO_ADDRESS || registryAddress === ZERO_ADDRESS || !vaultAddress || !chainId) return; @@ -342,7 +316,6 @@ export function VaultInitializationModal() { vaultContract, refetchAdapter, close, - connectedAccount, registryAddress, selectedAgent, adapterAddress, diff --git a/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsContent.tsx b/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsContent.tsx index b6e8e6af..1f163b07 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsContent.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsContent.tsx @@ -60,6 +60,8 @@ export function VaultSettingsContent({ return (
void; onClose: () => void; }; -export function VaultSettingsHeader({ detailView, onBack, onClose }: VaultSettingsHeaderProps) { +export function VaultSettingsHeader({ vaultAddress, chainId, detailView, onBack, onClose }: VaultSettingsHeaderProps) { const title = detailView ? VAULT_DETAIL_TITLES[detailView] : 'Vault Settings'; + const { refetch, isRefetching } = useVaultQueryRefresh({ vaultAddress, chainId }); return ( void refetch({ includeRetries: true })} + disabled={isRefetching} + className="flex h-8 w-8 items-center justify-center rounded-full text-secondary transition-colors hover:bg-surface hover:text-primary disabled:cursor-not-allowed disabled:opacity-50" + aria-label="Refresh vault settings" + > + + + )} title={title} showBack={!!detailView} onBack={onBack} diff --git a/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditCapsDetail.tsx b/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditCapsDetail.tsx index 6e5f97b2..a689d3af 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditCapsDetail.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditCapsDetail.tsx @@ -2,9 +2,9 @@ import type { Address } from 'viem'; import { useConnection } from 'wagmi'; +import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultV2 } from '@/hooks/useVaultV2'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; import type { SupportedNetworks } from '@/utils/networks'; import { EditCaps } from '../../../settings/EditCaps'; @@ -25,7 +25,7 @@ export function EditCapsDetail({ vaultAddress, chainId, onBack }: EditCapsDetail connectedAddress, onTransactionSuccess: onBack, // Navigate AFTER tx confirms, not when sent }); - const { morphoMarketV1Adapter: adapterAddress } = useMorphoMarketV1Adapters({ + const { primaryAdapter: adapterAddress } = useMorphoMarketAdapters({ vaultAddress, chainId, }); diff --git a/src/features/autovault/components/vault-detail/modals/vault-settings/panels/RolesPanel.tsx b/src/features/autovault/components/vault-detail/modals/vault-settings/panels/RolesPanel.tsx index 9e6d50df..30024ccc 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-settings/panels/RolesPanel.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-settings/panels/RolesPanel.tsx @@ -4,7 +4,7 @@ import type { Address } from 'viem'; import { zeroAddress } from 'viem'; import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; +import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultV2 } from '@/hooks/useVaultV2'; import type { SupportedNetworks } from '@/utils/networks'; @@ -27,15 +27,26 @@ export function RolesPanel({ vaultAddress, chainId, onNavigateToDetail }: RolesP chainId, connectedAddress, }); - const { morphoMarketV1Adapter } = useMorphoMarketV1Adapters({ vaultAddress, chainId }); + const { adapters: marketAdapters, primaryAdapter } = useMorphoMarketAdapters({ vaultAddress, chainId }); const owner = vaultData?.owner; const curator = vaultData?.curator; const allocators = vaultData?.allocators ?? []; const adapters = vaultData?.adapters ?? []; - const isMarketV1Adapter = (addr: string) => - morphoMarketV1Adapter !== zeroAddress && addr.toLowerCase() === morphoMarketV1Adapter.toLowerCase(); + const getAdapterLabel = (addr: string) => { + const adapter = marketAdapters.find((candidate) => candidate.adapter.toLowerCase() === addr.toLowerCase()); + if (!adapter) { + return primaryAdapter !== zeroAddress && addr.toLowerCase() === primaryAdapter.toLowerCase() ? 'MorphoBlue Adapter' : undefined; + } + if (adapter.adapterType === 'MorphoMarketV1AdapterV2') { + return 'MorphoBlue Adapter V2'; + } + if (adapter.adapterType === 'MorphoMarketV1Adapter') { + return 'MorphoBlue Adapter V1'; + } + return 'MorphoBlue Adapter'; + }; const renderRoleSection = ( label: string, @@ -114,7 +125,7 @@ export function RolesPanel({ vaultAddress, chainId, onNavigateToDetail }: RolesP {/* Adapters */} {renderRoleSection('Adapters', 'Contracts enabling vault interactions with underlying protocols.', adapters, { - getLabelOverride: (addr) => (isMarketV1Adapter(addr) ? 'MorphoBlue Adapter' : undefined), + getLabelOverride: getAdapterLabel, })}
); diff --git a/src/features/autovault/vault-view.tsx b/src/features/autovault/vault-view.tsx index 57cb37a9..bf56e4a3 100644 --- a/src/features/autovault/vault-view.tsx +++ b/src/features/autovault/vault-view.tsx @@ -8,9 +8,10 @@ import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; import Header from '@/components/layout/header/Header'; import { useVaultPage } from '@/hooks/useVaultPage'; +import { useVaultQueryRefresh } from '@/hooks/useVaultQueryRefresh'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultV2 } from '@/hooks/useVaultV2'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; +import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { getSlicedAddress } from '@/utils/address'; import { ALL_SUPPORTED_NETWORKS, SupportedNetworks, getNetworkConfig } from '@/utils/networks'; import { parseCapIdParams } from '@/utils/morpho'; @@ -67,7 +68,7 @@ export default function VaultContent() { connectedAddress, onTransactionSuccess: vaultDataQuery.refetch, }); - const adapterQuery = useMorphoMarketV1Adapters({ vaultAddress: vaultAddressValue, chainId }); + const adapterQuery = useMorphoMarketAdapters({ vaultAddress: vaultAddressValue, chainId }); // Only use useVaultPage for complex computed state const { vaultAPY, isVaultInitialized, needsInitialization } = useVaultPage({ @@ -75,18 +76,17 @@ export default function VaultContent() { chainId, connectedAddress, }); - - const refetchVaultData = vaultDataQuery.refetch; - const refetchVaultContract = vaultContract.refetch; - const refetchAdapters = adapterQuery.refetch; + const { refetch: refetchVaultQueries, isRefetching: isRefetchingVaultQueries } = useVaultQueryRefresh({ + vaultAddress: vaultAddressValue, + chainId, + }); const handleRefreshVault = useCallback(() => { - void refetchVaultData(); - void refetchVaultContract(); - void refetchAdapters(); - }, [refetchVaultData, refetchVaultContract, refetchAdapters]); + void vaultContract.refetch(); + void refetchVaultQueries({ includeRetries: true }); + }, [refetchVaultQueries, vaultContract]); - const isRefetching = vaultDataQuery.isRefetching || vaultContract.isRefetching || adapterQuery.isRefetching; + const isRefetching = vaultDataQuery.isRefetching || vaultContract.isRefetching || adapterQuery.isRefetching || isRefetchingVaultQueries; // Extract minimal data for vault-view rendering const vaultData = vaultDataQuery.data; @@ -97,7 +97,7 @@ export default function VaultContent() { const tokenDecimals = vaultData?.tokenDecimals; const tokenSymbol = vaultData?.tokenSymbol; const assetAddress = vaultData?.assetAddress as Address | undefined; - const adapterAddress = adapterQuery.morphoMarketV1Adapter as Address | undefined; + const adapterAddress = adapterQuery.primaryAdapter as Address | undefined; const adapterPortfolioHref = useMemo(() => { if (!adapterAddress || !assetAddress) return undefined; @@ -238,7 +238,7 @@ export default function VaultContent() { /> {/* Setup Banner - Show if vault needs initialization */} - {needsInitialization && vaultContract.isOwner && networkConfig?.vaultConfig?.marketV1AdapterFactory && ( + {needsInitialization && vaultContract.isOwner && networkConfig?.vaultConfig?.marketAdapterFactory && (

Complete vault setup

@@ -321,7 +321,7 @@ export default function VaultContent() {
{/* Initialization Modal - Pulls own data from URL params */} - {networkConfig?.vaultConfig?.marketV1AdapterFactory && } + {networkConfig?.vaultConfig?.marketAdapterFactory && }
); } diff --git a/src/hooks/useDeployMorphoMarketV1Adapter.ts b/src/hooks/useDeployMorphoMarketAdapter.ts similarity index 53% rename from src/hooks/useDeployMorphoMarketV1Adapter.ts rename to src/hooks/useDeployMorphoMarketAdapter.ts index 05066942..821f2d41 100644 --- a/src/hooks/useDeployMorphoMarketV1Adapter.ts +++ b/src/hooks/useDeployMorphoMarketAdapter.ts @@ -1,21 +1,18 @@ import { useCallback, useMemo } from 'react'; -import { type Address, encodeFunctionData, zeroAddress } from 'viem'; +import { type Address, encodeFunctionData } from 'viem'; import { useConnection, useChainId } from 'wagmi'; -import { adapterFactoryAbi } from '@/abis/morpho-market-v1-adapter-factory'; -import { getMorphoAddress } from '@/utils/morpho'; -import { getNetworkConfig, type SupportedNetworks } from '@/utils/networks'; +import { adapterV2FactoryAbi } from '@/abis/morpho-market-v1-adapter-v2-factory'; +import { type SupportedNetworks, getAgentConfig } from '@/utils/networks'; import { useTransactionWithToast } from './useTransactionWithToast'; const TX_TOAST_ID = 'deploy-morpho-market-adapter'; -export function useDeployMorphoMarketV1Adapter({ +export function useDeployMorphoMarketAdapter({ vaultAddress, chainId, - morphoAddress, }: { vaultAddress?: Address; chainId?: SupportedNetworks | number; - morphoAddress?: Address; }) { const { address: account } = useConnection(); const connectedChainId = useChainId(); @@ -23,49 +20,44 @@ export function useDeployMorphoMarketV1Adapter({ const factoryAddress = useMemo(() => { try { - return getNetworkConfig(resolvedChainId).vaultConfig?.marketV1AdapterFactory ?? null; + return getAgentConfig(resolvedChainId)?.marketAdapterFactory ?? null; } catch (_error) { return null; } }, [resolvedChainId]); - const morpho = useMemo(() => { - if (morphoAddress) return morphoAddress; - return getMorphoAddress(resolvedChainId); - }, [morphoAddress, resolvedChainId]); - - const canDeploy = Boolean(factoryAddress && vaultAddress && morpho && morpho !== zeroAddress); + const canDeploy = Boolean(factoryAddress && vaultAddress); const { isConfirming: isDeploying, sendTransactionAsync } = useTransactionWithToast({ toastId: TX_TOAST_ID, pendingText: 'Deploying adapter', successText: 'Adapter deployed', errorText: 'Failed to deploy adapter', - pendingDescription: 'Creating Morpho Market V1 adapter for this vault', + pendingDescription: 'Creating Morpho market adapter V2 for this vault', successDescription: 'Adapter created. It may take a few seconds for data to index.', chainId: resolvedChainId, }); const deploy = useCallback(async (): Promise<`0x${string}` | undefined> => { - if (!canDeploy || !account) return undefined; + if (!canDeploy || !account || !factoryAddress || !vaultAddress) return undefined; const txHash = await sendTransactionAsync({ account, - to: factoryAddress as Address, + to: factoryAddress, data: encodeFunctionData({ - abi: adapterFactoryAbi, - functionName: 'createMorphoMarketV1Adapter', - args: [vaultAddress as Address, morpho as Address], + abi: adapterV2FactoryAbi, + functionName: 'createMorphoMarketV1AdapterV2', + args: [vaultAddress], }), }); return txHash; - }, [account, canDeploy, factoryAddress, morpho, sendTransactionAsync, vaultAddress]); + }, [account, canDeploy, factoryAddress, sendTransactionAsync, vaultAddress]); return { + canDeploy, deploy, - isDeploying, factoryAddress, - canDeploy, + isDeploying, }; } diff --git a/src/hooks/useMorphoMarketAdapters.ts b/src/hooks/useMorphoMarketAdapters.ts new file mode 100644 index 00000000..bedc0e8f --- /dev/null +++ b/src/hooks/useMorphoMarketAdapters.ts @@ -0,0 +1,54 @@ +import { useMemo } from 'react'; +import { type Address, zeroAddress } from 'viem'; +import { useVaultV2Data } from './useVaultV2Data'; +import type { SupportedNetworks } from '@/utils/networks'; + +export type VaultMarketAdapter = { + adapter: Address; + adapterType?: string; + factoryAddress?: Address; + id: string; + parentVault: Address; +}; + +export function useMorphoMarketAdapters({ vaultAddress, chainId }: { vaultAddress?: Address; chainId: SupportedNetworks }) { + const query = useVaultV2Data({ vaultAddress, chainId }); + + const adapters = useMemo(() => { + const adapterDetails = query.data?.adapterDetails ?? []; + + if (adapterDetails.length > 0) { + return adapterDetails.map((adapterDetail) => ({ + adapter: adapterDetail.address as Address, + adapterType: adapterDetail.adapterType, + factoryAddress: adapterDetail.factoryAddress as Address, + id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterDetail.address}`, + parentVault: (vaultAddress ?? zeroAddress) as Address, + })); + } + + return (query.data?.adapters ?? []).map((adapterAddress) => ({ + adapter: adapterAddress as Address, + adapterType: undefined, + factoryAddress: undefined, + id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, + parentVault: (vaultAddress ?? zeroAddress) as Address, + })); + }, [chainId, query.data?.adapterDetails, query.data?.adapters, vaultAddress]); + + const primaryAdapter = useMemo(() => (adapters.length > 0 ? adapters[0].adapter : zeroAddress), [adapters]); + const primaryAdapterType = adapters[0]?.adapterType; + const primaryFactoryAddress = adapters[0]?.factoryAddress; + + return { + primaryAdapter, + primaryAdapterType, + primaryFactoryAddress, + adapters, + isLoading: query.isLoading, + error: query.error, + refetch: query.refetch, + isRefetching: query.isRefetching, + hasAdapters: adapters.length > 0, + }; +} diff --git a/src/hooks/useMorphoMarketV1Adapters.ts b/src/hooks/useMorphoMarketV1Adapters.ts deleted file mode 100644 index 0cf87469..00000000 --- a/src/hooks/useMorphoMarketV1Adapters.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useMemo } from 'react'; -import { type Address, zeroAddress } from 'viem'; -import { useVaultV2Data } from './useVaultV2Data'; -import type { SupportedNetworks } from '@/utils/networks'; - -export function useMorphoMarketV1Adapters({ vaultAddress, chainId }: { vaultAddress?: Address; chainId: SupportedNetworks }) { - const query = useVaultV2Data({ vaultAddress, chainId }); - - const adapters = useMemo( - () => - (query.data?.adapters ?? []).map((adapterAddress) => ({ - adapter: adapterAddress as Address, - id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, - parentVault: (vaultAddress ?? zeroAddress) as Address, - })), - [chainId, query.data?.adapters, vaultAddress], - ); - - const morphoMarketV1Adapter = useMemo(() => (adapters.length > 0 ? adapters[0].adapter : zeroAddress), [adapters]); - - return { - morphoMarketV1Adapter, - adapters, - isLoading: query.isLoading, - error: query.error, - refetch: query.refetch, - isRefetching: query.isRefetching, - hasAdapters: adapters.length > 0, - }; -} diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts index 2ec37532..adafdc1e 100644 --- a/src/hooks/useVaultPage.ts +++ b/src/hooks/useVaultPage.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import { type Address, formatUnits, zeroAddress } from 'viem'; import type { SupportedNetworks } from '@/utils/networks'; -import { useMorphoMarketV1Adapters } from './useMorphoMarketV1Adapters'; +import { useMorphoMarketAdapters } from './useMorphoMarketAdapters'; import useUserPositionsSummaryData from './useUserPositionsSummaryData'; import { useVaultAllocations } from './useVaultAllocations'; import { useVaultV2 } from './useVaultV2'; @@ -30,24 +30,24 @@ export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVau // Pull only what we need for computations const vaultDataQuery = useVaultV2Data({ vaultAddress, chainId }); const contract = useVaultV2({ vaultAddress, chainId, connectedAddress, onTransactionSuccess: vaultDataQuery.refetch }); - const adapterQuery = useMorphoMarketV1Adapters({ vaultAddress, chainId }); + const adapterQuery = useMorphoMarketAdapters({ vaultAddress, chainId }); const allocationsQuery = useVaultAllocations({ vaultAddress, chainId }); // Complex derived state: isVaultInitialized (needs multiple sources) const isVaultInitialized = useMemo(() => { if (adapterQuery.isLoading || vaultDataQuery.isLoading) return false; - if (adapterQuery.morphoMarketV1Adapter === zeroAddress) return false; + if (adapterQuery.primaryAdapter === zeroAddress) return false; return vaultDataQuery.data !== null && vaultDataQuery.data !== undefined; - }, [adapterQuery.isLoading, adapterQuery.morphoMarketV1Adapter, vaultDataQuery.isLoading, vaultDataQuery.data]); + }, [adapterQuery.isLoading, adapterQuery.primaryAdapter, vaultDataQuery.isLoading, vaultDataQuery.data]); const needsAdapterDeployment = useMemo( - () => !adapterQuery.isLoading && adapterQuery.morphoMarketV1Adapter === zeroAddress, - [adapterQuery.isLoading, adapterQuery.morphoMarketV1Adapter], + () => !adapterQuery.isLoading && adapterQuery.primaryAdapter === zeroAddress, + [adapterQuery.isLoading, adapterQuery.primaryAdapter], ); // Fetch adapter positions for APY calculation const { positions: adapterPositions, isEarningsLoading: isAPYLoading } = useUserPositionsSummaryData( - !needsAdapterDeployment && adapterQuery.morphoMarketV1Adapter !== zeroAddress ? adapterQuery.morphoMarketV1Adapter : undefined, + !needsAdapterDeployment && adapterQuery.primaryAdapter !== zeroAddress ? adapterQuery.primaryAdapter : undefined, 'day', [chainId], ); diff --git a/src/hooks/useVaultQueryRefresh.ts b/src/hooks/useVaultQueryRefresh.ts new file mode 100644 index 00000000..3ab02556 --- /dev/null +++ b/src/hooks/useVaultQueryRefresh.ts @@ -0,0 +1,68 @@ +import { useCallback } from 'react'; +import { type QueryClient, useIsFetching, useQueryClient } from '@tanstack/react-query'; +import type { Address } from 'viem'; +import type { SupportedNetworks } from '@/utils/networks'; + +type RefetchVaultQueryDataArgs = { + vaultAddress?: Address; + chainId: SupportedNetworks; + retryDelaysMs?: readonly number[]; +}; + +const DEFAULT_REFETCH_DELAYS_MS = [0] as const; +export const MONARCH_VAULT_QUERY_REFETCH_DELAYS_MS = [0, 1_500, 5_000] as const; + +const wait = async (delayMs: number): Promise => { + if (delayMs <= 0) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, delayMs); + }); +}; + +const refetchVaultQuerySet = async (queryClient: QueryClient, vaultAddress: Address, chainId: SupportedNetworks): Promise => { + await Promise.all([ + queryClient.refetchQueries({ queryKey: ['vault-v2-data', vaultAddress, chainId], exact: false }), + queryClient.refetchQueries({ queryKey: ['vault-allocations', vaultAddress, chainId], exact: false }), + queryClient.refetchQueries({ queryKey: ['user-vaults-v2'], exact: false }), + ]); +}; + +export const refetchVaultQueryData = async ( + queryClient: QueryClient, + { vaultAddress, chainId, retryDelaysMs = DEFAULT_REFETCH_DELAYS_MS }: RefetchVaultQueryDataArgs, +): Promise => { + if (!vaultAddress) { + return; + } + + for (const delayMs of retryDelaysMs) { + await wait(delayMs); + await refetchVaultQuerySet(queryClient, vaultAddress, chainId); + } +}; + +export function useVaultQueryRefresh({ vaultAddress, chainId }: { vaultAddress?: Address; chainId: SupportedNetworks }) { + const queryClient = useQueryClient(); + const vaultDataFetchCount = useIsFetching({ queryKey: ['vault-v2-data', vaultAddress, chainId] }); + const vaultAllocationFetchCount = useIsFetching({ queryKey: ['vault-allocations', vaultAddress, chainId] }); + const userVaultFetchCount = useIsFetching({ queryKey: ['user-vaults-v2'] }); + + const refetch = useCallback( + async ({ includeRetries = false }: { includeRetries?: boolean } = {}): Promise => { + await refetchVaultQueryData(queryClient, { + vaultAddress, + chainId, + retryDelaysMs: includeRetries ? MONARCH_VAULT_QUERY_REFETCH_DELAYS_MS : DEFAULT_REFETCH_DELAYS_MS, + }); + }, + [chainId, queryClient, vaultAddress], + ); + + return { + refetch, + isRefetching: vaultDataFetchCount + vaultAllocationFetchCount + userVaultFetchCount > 0, + }; +} diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 9a682779..c92a54b6 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -5,6 +5,7 @@ import { useConnection, useChainId, useReadContracts } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; import type { VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import type { SupportedNetworks } from '@/utils/networks'; +import { MONARCH_VAULT_QUERY_REFETCH_DELAYS_MS, refetchVaultQueryData } from './useVaultQueryRefresh'; import { useTransactionWithToast } from './useTransactionWithToast'; import type { Market } from '@/utils/types'; import { encodeMarketParams } from '@/utils/morpho'; @@ -153,6 +154,19 @@ export function useVaultV2({ return (userShares * totalAssets) / totalSupply; }, [connectedAddress, userShares, totalAssets, totalSupply]); + const refreshVaultStateAfterTransaction = useCallback( + (includeMonarchRetries: boolean) => { + void refetchAll(); + void refetchVaultQueryData(queryClient, { + vaultAddress, + chainId: chainIdToUse, + retryDelaysMs: includeMonarchRetries ? MONARCH_VAULT_QUERY_REFETCH_DELAYS_MS : undefined, + }); + onTransactionSuccess?.(); + }, + [chainIdToUse, onTransactionSuccess, queryClient, refetchAll, vaultAddress], + ); + const { isConfirming: isInitializing, sendTransactionAsync: sendInitializationTx } = useTransactionWithToast({ toastId: `init-${vaultAddress ?? 'unknown'}`, pendingText: 'Completing vault initialization', @@ -162,9 +176,7 @@ export function useVaultV2({ successDescription: 'Vault is ready to use', chainId: chainIdToUse, onSuccess: () => { - void refetchAll(); - void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); - onTransactionSuccess?.(); + refreshVaultStateAfterTransaction(true); }, }); @@ -176,7 +188,9 @@ export function useVaultV2({ pendingDescription: 'Applying new name and symbol', successDescription: 'Vault metadata saved', chainId: chainIdToUse, - onSuccess: onTransactionSuccess, + onSuccess: () => { + refreshVaultStateAfterTransaction(true); + }, }); const { isConfirming: isUpdatingAllocator, sendTransactionAsync: sendAllocatorTx } = useTransactionWithToast({ @@ -187,7 +201,9 @@ export function useVaultV2({ pendingDescription: 'Updating allocator status', successDescription: 'Allocator status changed', chainId: chainIdToUse, - onSuccess: onTransactionSuccess, + onSuccess: () => { + refreshVaultStateAfterTransaction(true); + }, }); const { isConfirming: isSwappingAllocator, sendTransactionAsync: sendSwapAllocatorTx } = useTransactionWithToast({ @@ -198,7 +214,9 @@ export function useVaultV2({ pendingDescription: 'Changing from old to new allocator', successDescription: 'Allocators swapped successfully', chainId: chainIdToUse, - onSuccess: onTransactionSuccess, + onSuccess: () => { + refreshVaultStateAfterTransaction(true); + }, }); const { isConfirming: isUpdatingCaps, sendTransactionAsync: sendCapsTx } = useTransactionWithToast({ @@ -209,7 +227,9 @@ export function useVaultV2({ pendingDescription: 'Applying new market caps', successDescription: 'Caps updated successfully', chainId: chainIdToUse, - onSuccess: onTransactionSuccess, + onSuccess: () => { + refreshVaultStateAfterTransaction(true); + }, }); // All morpho v2 vault operations have to be proposed first, and then execute @@ -474,8 +494,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); - return true; } catch (allocatorError) { if (allocatorError instanceof Error && allocatorError.message.toLowerCase().includes('reject')) { @@ -543,8 +561,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); - return true; } catch (swapError) { if (swapError instanceof Error && swapError.message.toLowerCase().includes('reject')) { @@ -656,9 +672,6 @@ export function useVaultV2({ chainId: chainIdToUse, }); - void queryClient.invalidateQueries({ queryKey: ['vault-v2-data', vaultAddress, chainIdToUse] }); - void queryClient.invalidateQueries({ queryKey: ['vault-allocations', vaultAddress, chainIdToUse] }); - return true; } catch (capsError) { if (capsError instanceof Error && capsError.message.toLowerCase().includes('reject')) { @@ -679,7 +692,9 @@ export function useVaultV2({ pendingDescription: 'Depositing assets to vault', successDescription: 'Assets deposited successfully', chainId: chainIdToUse, - onSuccess: onTransactionSuccess, + onSuccess: () => { + refreshVaultStateAfterTransaction(false); + }, }); const { isConfirming: isWithdrawing, sendTransactionAsync: sendWithdrawTx } = useTransactionWithToast({ @@ -690,7 +705,9 @@ export function useVaultV2({ pendingDescription: 'Withdrawing assets from vault', successDescription: 'Assets withdrawn successfully', chainId: chainIdToUse, - onSuccess: onTransactionSuccess, + onSuccess: () => { + refreshVaultStateAfterTransaction(false); + }, }); const deposit = useCallback( diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index c0960c35..f8b79af2 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -2,7 +2,7 @@ import type { Address } from 'viem'; import { zeroAddress } from 'viem'; import { useQuery } from '@tanstack/react-query'; import { vaultv2Abi } from '@/abis/vaultv2'; -import { fetchMonarchVaultDetails, type VaultV2Cap } from '@/data-sources/monarch-api/vaults'; +import { fetchMonarchVaultDetails, type VaultAdapterDetails, type VaultV2Cap } from '@/data-sources/monarch-api/vaults'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; import { getSlicedAddress } from '@/utils/address'; import { parseCapIdParams } from '@/utils/morpho'; @@ -35,6 +35,7 @@ export type VaultV2Data = { curator: string; capsData: CapData; adapters: string[]; + adapterDetails: VaultAdapterDetails[]; curatorDisplay: string; }; @@ -167,6 +168,7 @@ export function useVaultV2Data({ vaultAddress, chainId, fallbackName = '', fallb needSetupCaps: !adapterCap || collateralCaps.length === 0 || marketCaps.length === 0, }, adapters: monarchVault?.adapters ?? rpcFallback?.adapters ?? [], + adapterDetails: monarchVault?.adapterDetails ?? [], curatorDisplay: curator ? getSlicedAddress(curator as Address) : '--', } satisfies VaultV2Data; }, diff --git a/src/modals/vault/vault-withdraw-modal.tsx b/src/modals/vault/vault-withdraw-modal.tsx index f6e929b7..e6af90d5 100644 --- a/src/modals/vault/vault-withdraw-modal.tsx +++ b/src/modals/vault/vault-withdraw-modal.tsx @@ -8,10 +8,10 @@ import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import Input from '@/components/Input/Input'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { MarketIdentity, MarketIdentityMode } from '@/features/markets/components/market-identity'; +import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; import { useVaultAllocations } from '@/hooks/useVaultAllocations'; import { useVaultV2 } from '@/hooks/useVaultV2'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; -import { useMorphoMarketV1Adapters } from '@/hooks/useMorphoMarketV1Adapters'; import { formatBalance, formatReadable } from '@/utils/balance'; import type { SupportedNetworks } from '@/utils/networks'; import type { MarketAllocation } from '@/types/vaultAllocations'; @@ -47,7 +47,7 @@ export function VaultWithdrawModal({ // Fetch vault data const { data: vaultData } = useVaultV2Data({ vaultAddress, chainId }); const { marketAllocations, loading: allocationsLoading } = useVaultAllocations({ vaultAddress, chainId }); - const { morphoMarketV1Adapter, isLoading: adaptersLoading } = useMorphoMarketV1Adapters({ vaultAddress, chainId }); + const { primaryAdapter, isLoading: adaptersLoading } = useMorphoMarketAdapters({ vaultAddress, chainId }); // Vault hook for transactions const { withdrawFromMarket, isWithdrawing, isOwner } = useVaultV2({ @@ -90,13 +90,13 @@ export function VaultWithdrawModal({ // Handle withdraw const handleWithdraw = useCallback(async () => { - if (!connectedAddress || !selectedMarket || !morphoMarketV1Adapter) return; + if (!connectedAddress || !selectedMarket || !primaryAdapter) return; // Determine if we need to set self as allocator const needsAllocatorSetup = isOwner && !isAllocator; - await withdrawFromMarket(withdrawAmount, connectedAddress, selectedMarket.market, morphoMarketV1Adapter, needsAllocatorSetup); - }, [connectedAddress, selectedMarket, morphoMarketV1Adapter, isOwner, isAllocator, withdrawFromMarket, withdrawAmount]); + await withdrawFromMarket(withdrawAmount, connectedAddress, selectedMarket.market, primaryAdapter, needsAllocatorSetup); + }, [connectedAddress, selectedMarket, primaryAdapter, isOwner, isAllocator, withdrawFromMarket, withdrawAmount]); const isLoading = allocationsLoading || adaptersLoading; diff --git a/src/utils/networks.ts b/src/utils/networks.ts index c9397840..78eb35dd 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -61,7 +61,7 @@ export const hyperEvm = defineChain({ type VaultAgentConfig = { v2FactoryAddress: Address; morphoRegistry: Address; // the RegistryList contract deployed by morpho! - marketV1AdapterFactory: Address; // MorphoMarketV1AdapterFactory contract used to create adapters for markets + marketAdapterFactory: Address; // MorphoMarketV1AdapterV2Factory used to create adapters for vault markets strategies?: AgentMetadata[]; }; @@ -107,7 +107,7 @@ export const networks: NetworkConfig[] = [ v2FactoryAddress: '0x4501125508079A99ebBebCE205DeC9593C2b5857', strategies: v2AgentsBase, morphoRegistry: '0x5C2531Cbd2cf112Cf687da3Cd536708aDd7DB10a', - marketV1AdapterFactory: '0x133baC94306B99f6dAD85c381a5be851d8DD717c', + marketAdapterFactory: '0x9a1B378C43BA535cDB89934230F0D3890c51C0EB', }, blocktime: 2, maxBlockDelay: 5, From f3a61ab237e14d66a292fa66426cb7e3b4c56097 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 19:38:22 +0800 Subject: [PATCH 3/7] chore: lint --- src/data-sources/monarch-api/index.ts | 2 - src/data-sources/monarch-api/vaults.ts | 47 ------ src/data-sources/morpho-api/vaults.ts | 8 +- src/features/autovault/vault-list-view.tsx | 168 +++++++++++++-------- src/hooks/queries/useUserVaultsV2Query.ts | 54 ++++--- src/hooks/useVaultV2.ts | 32 ++-- src/hooks/useVaultV2Data.ts | 8 +- src/utils/vaultAllocation.ts | 13 +- 8 files changed, 176 insertions(+), 156 deletions(-) diff --git a/src/data-sources/monarch-api/index.ts b/src/data-sources/monarch-api/index.ts index 3b821a4e..14b6f5b7 100644 --- a/src/data-sources/monarch-api/index.ts +++ b/src/data-sources/monarch-api/index.ts @@ -1,11 +1,9 @@ export { monarchGraphqlFetcher } from './fetchers'; export { fetchMonarchVaultDetails, - fetchUserVaultV2AddressesAllNetworks, fetchUserVaultV2DetailsAllNetworks, type VaultAdapterDetails, type UserVaultV2, - type UserVaultV2Address, type VaultV2Cap, type VaultV2Details, } from './vaults'; diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts index 334a9556..010ff752 100644 --- a/src/data-sources/monarch-api/vaults.ts +++ b/src/data-sources/monarch-api/vaults.ts @@ -33,11 +33,6 @@ export type VaultV2Details = { avgApy?: number; }; -export type UserVaultV2Address = { - address: string; - networkId: SupportedNetworks; -}; - export type UserVaultV2 = VaultV2Details & { networkId: SupportedNetworks; balance?: bigint; @@ -104,14 +99,6 @@ type MonarchVaultDetailResponse = { }; }; -type MonarchVaultAddressRecord = Pick; - -type MonarchVaultAddressesResponse = { - data?: { - Vault?: MonarchVaultAddressRecord[]; - }; -}; - const MONARCH_VAULT_FIELDS = ` id vaultAddress @@ -142,11 +129,6 @@ const MONARCH_VAULT_FIELDS = ` } `; -const MONARCH_VAULT_ADDRESS_FIELDS = ` - vaultAddress - chainId -`; - const normalizeAddress = (value: string | null | undefined): string => value?.toLowerCase() ?? ''; const toSupportedNetwork = (chainId: number): SupportedNetworks | null => { @@ -198,18 +180,6 @@ const transformVault = (vault: MonarchVault, adapterDetails: VaultAdapterDetails }; }; -const transformVaultAddress = (vault: MonarchVaultAddressRecord): UserVaultV2Address | null => { - const networkId = toSupportedNetwork(vault.chainId); - if (!networkId) { - return null; - } - - return { - address: normalizeAddress(vault.vaultAddress), - networkId, - }; -}; - const userVaultsQuery = ` query MonarchUserVaults($owner: String!) { Vault(where: { owner: { _eq: $owner } }, order_by: [{ lastUpdate: desc }]) { @@ -218,14 +188,6 @@ const userVaultsQuery = ` } `; -const userVaultAddressesQuery = ` - query MonarchUserVaultAddresses($owner: String!) { - Vault(where: { owner: { _eq: $owner } }, order_by: [{ lastUpdate: desc }]) { - ${MONARCH_VAULT_ADDRESS_FIELDS} - } - } -`; - const vaultByAddressQuery = ` query MonarchVaultByAddress($address: String!, $chainId: Int!) { Vault(where: { vaultAddress: { _eq: $address }, chainId: { _eq: $chainId } }, limit: 1) { @@ -250,15 +212,6 @@ export const fetchUserVaultV2DetailsAllNetworks = async (owner: string): Promise return vaults.map((vault) => transformVault(vault)).filter((vault): vault is UserVaultV2 => vault !== null); }; -export const fetchUserVaultV2AddressesAllNetworks = async (owner: string): Promise => { - const response = await monarchGraphqlFetcher(userVaultAddressesQuery, { - owner: owner.toLowerCase(), - }); - - const vaults = response.data?.Vault ?? []; - return vaults.map(transformVaultAddress).filter((vault): vault is UserVaultV2Address => vault !== null); -}; - export const fetchMonarchVaultDetails = async (vaultAddress: string, chainId: SupportedNetworks): Promise => { const response = await monarchGraphqlFetcher(vaultByAddressQuery, { address: vaultAddress.toLowerCase(), diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts index 961184a7..94857d4e 100644 --- a/src/data-sources/morpho-api/vaults.ts +++ b/src/data-sources/morpho-api/vaults.ts @@ -1,7 +1,11 @@ import { allVaultsQuery, vaultApysQuery } from '@/graphql/vault-queries'; -import type { UserVaultV2Address } from '@/data-sources/monarch-api/vaults'; import { morphoGraphqlFetcher } from './fetchers'; +type VaultAddressByNetwork = { + address: string; + networkId: number; +}; + // Constants for Morpho vault fetching const MORPHO_SUPPORTED_CHAIN_IDS = [1, 8453, 999, 137, 42_161, 130]; const MAX_VAULTS_LIMIT = 500; @@ -102,7 +106,7 @@ export const fetchAllMorphoVaults = async (): Promise => { } }; -export const fetchMorphoVaultApys = async (vaults: UserVaultV2Address[]): Promise> => { +export const fetchMorphoVaultApys = async (vaults: VaultAddressByNetwork[]): Promise> => { if (vaults.length === 0) { return new Map(); } diff --git a/src/features/autovault/vault-list-view.tsx b/src/features/autovault/vault-list-view.tsx index 2314324a..a69d9787 100644 --- a/src/features/autovault/vault-list-view.tsx +++ b/src/features/autovault/vault-list-view.tsx @@ -7,16 +7,32 @@ import { GearIcon } from '@radix-ui/react-icons'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { useRouter } from 'next/navigation'; import { useAppKit } from '@reown/appkit/react'; +import { type Address } from 'viem'; import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; import { Avatar } from '@/components/Avatar/Avatar'; +import { TokenIcon } from '@/components/shared/token-icon'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import Header from '@/components/layout/header/Header'; -import { fetchUserVaultV2AddressesAllNetworks } from '@/data-sources/monarch-api/vaults'; +import { useUserVaultsV2Query } from '@/hooks/queries/useUserVaultsV2Query'; +import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; +import { getNetworkName } from '@/utils/networks'; import { getDeployedVaults } from '@/utils/vault-storage'; import { DeploymentModal } from './components/deployment/deployment-modal'; import { SectionTag, FeatureCard } from '@/components/landing'; +type VaultListItem = { + address: string; + asset?: string; + name?: string; + networkId: number; + symbol?: string; +}; + +const getVaultLabel = (vault: VaultListItem): string => { + return vault.name?.trim() || vault.symbol?.trim() || `${vault.address.slice(0, 6)}...${vault.address.slice(-4)}`; +}; + // Skeleton component for loading state function PageSkeleton() { return ( @@ -49,51 +65,20 @@ export default function AutovaultListContent() { const router = useRouter(); const { open } = useAppKit(); const { isConnected, address } = useConnection(); + const { findToken } = useTokensQuery(); const [showDeploymentModal, setShowDeploymentModal] = useState(false); const [hasMounted, setHasMounted] = useState(false); - const [vaultAddresses, setVaultAddresses] = useState<{ address: string; networkId: number }[]>([]); - const [vaultsLoading, setVaultsLoading] = useState(false); - const [fetchError, setFetchError] = useState(null); useEffect(() => { setHasMounted(true); }, []); - // Fetch vault addresses from Monarch API and merge with local optimistic vaults below. - useEffect(() => { - if (!address || !isConnected) { - setVaultAddresses([]); - setFetchError(null); - return; - } - - const fetchVaults = async () => { - setVaultsLoading(true); - setFetchError(null); - try { - const addresses = await fetchUserVaultV2AddressesAllNetworks(address); - setVaultAddresses(addresses); - } catch (_error) { - setFetchError('Unable to load vaults. Please try again.'); - // Keep existing vault addresses if we had them (don't clear on error) - } finally { - setVaultsLoading(false); - } - }; - - void fetchVaults(); - }, [address, isConnected]); - - const handleRetryFetch = () => { - if (address && isConnected) { - setVaultsLoading(true); - setFetchError(null); - fetchUserVaultV2AddressesAllNetworks(address) - .then((addresses) => setVaultAddresses(addresses)) - .catch(() => setFetchError('Unable to load vaults. Please try again.')) - .finally(() => setVaultsLoading(false)); - } - }; + const userVaultsQuery = useUserVaultsV2Query({ + userAddress: address as Address | undefined, + enabled: hasMounted && isConnected && Boolean(address), + includeApy: false, + includeBalances: false, + }); const handleConnect = () => { open(); @@ -103,42 +88,51 @@ export default function AutovaultListContent() { setShowDeploymentModal(true); }; - // Merge locally stored vaults with API results (filtered by connected address) - const mergedVaultAddresses = useMemo(() => { - const apiVaults = vaultAddresses; + // Merge locally stored vaults with Monarch details for optimistic post-deploy visibility. + const mergedVaults = useMemo(() => { + const indexedVaults = userVaultsQuery.data ?? []; // Only get locally stored vaults for the currently connected address const localVaults = address ? getDeployedVaults(address) : []; - // Create a map of existing vaults by address+chainId for quick lookup - const existingVaults = new Set(apiVaults.map((v) => `${v.address.toLowerCase()}-${v.networkId}`)); + const combined: VaultListItem[] = indexedVaults.map((vault) => ({ + address: vault.address, + asset: vault.asset, + name: vault.name, + networkId: vault.networkId, + symbol: vault.symbol, + })); + const existingVaults = new Set(combined.map((vault) => `${vault.address.toLowerCase()}-${vault.networkId}`)); - // Add local vaults that aren't in API results yet - const combined = [...apiVaults]; for (const localVault of localVaults) { const key = `${localVault.address.toLowerCase()}-${localVault.chainId}`; if (!existingVaults.has(key)) { combined.push({ address: localVault.address, + asset: undefined, + name: undefined, networkId: localVault.chainId, + symbol: undefined, }); } } return combined; - }, [vaultAddresses, address]); + }, [address, userVaultsQuery.data]); const handleManageVault = (vaultAddress?: string, networkId?: number) => { if (vaultAddress && networkId) { router.push(`/autovault/${networkId}/${vaultAddress}`); - } else if (mergedVaultAddresses.length > 0) { - const firstVault = mergedVaultAddresses[0]; + } else if (mergedVaults.length > 0) { + const firstVault = mergedVaults[0]; router.push(`/autovault/${firstVault.networkId}/${firstVault.address}`); } }; - const hasVaults = mergedVaultAddresses.length > 0; - const hasSingleVault = mergedVaultAddresses.length === 1; - const hasMultipleVaults = mergedVaultAddresses.length > 1; + const hasVaults = mergedVaults.length > 0; + const hasSingleVault = mergedVaults.length === 1; + const hasMultipleVaults = mergedVaults.length > 1; + const fetchError = userVaultsQuery.error ? 'Unable to load vaults. Please try again.' : null; + const vaultsLoading = userVaultsQuery.isLoading; return (
@@ -165,10 +159,10 @@ export default function AutovaultListContent() {
)} @@ -198,10 +192,10 @@ export default function AutovaultListContent() { onClick={() => handleManageVault()} > - Manage {mergedVaultAddresses[0].address.slice(0, 6)} + Manage {getVaultLabel(mergedVaults[0])} )} @@ -215,15 +209,15 @@ export default function AutovaultListContent() { className="font-zen px-6" > - Manage {mergedVaultAddresses[0].address.slice(0, 6)} + Manage {getVaultLabel(mergedVaults[0])} - {mergedVaultAddresses.map((vault) => ( + {mergedVaults.map((vault) => ( handleManageVault(vault.address, vault.networkId)} @@ -235,7 +229,10 @@ export default function AutovaultListContent() { /> } > - {vault.address.slice(0, 6)} +
+ {getVaultLabel(vault)} + {getNetworkName(vault.networkId) ?? `Chain ${vault.networkId}`} +
))}
@@ -287,6 +284,55 @@ export default function AutovaultListContent() { )} + {isConnected && hasVaults && ( +
+ {mergedVaults.map((vault) => { + const assetToken = vault.asset ? findToken(vault.asset, vault.networkId) : undefined; + const displayName = getVaultLabel(vault); + const displaySymbol = vault.symbol?.trim(); + const isIndexed = Boolean(vault.name || vault.symbol || vault.asset); + + return ( + + ); + })} +
+ )} + {/* Benefits Section */}
vault.owner && vault.asset && vault.address); } -async function fetchAndProcessVaults(userAddress: Address): Promise { +const getVaultQueryKey = (address: string, networkId: number) => `${address.toLowerCase()}-${networkId}`; + +async function fetchAndProcessVaults({ + includeApy, + includeBalances, + userAddress, +}: { + includeApy: boolean; + includeBalances: boolean; + userAddress: Address; +}): Promise { const validVaults = filterValidVaults(await fetchUserVaultV2DetailsAllNetworks(userAddress)); if (validVaults.length === 0) { return []; } - const avgApyByVault = await fetchMorphoVaultApys( - validVaults.map((vault) => ({ - address: vault.address, - networkId: vault.networkId, - })), - ); - - // Step 2: Batch fetch user's share balances via multicall - const shareBalances = await fetchUserVaultShares( - validVaults.map((v) => ({ address: v.address as Address, networkId: v.networkId })), - userAddress, - ); + const [avgApyByVault, shareBalances] = await Promise.all([ + includeApy + ? fetchMorphoVaultApys( + validVaults.map((vault) => ({ + address: vault.address, + networkId: vault.networkId, + })), + ) + : Promise.resolve(new Map()), + includeBalances + ? fetchUserVaultShares( + validVaults.map((v) => ({ address: v.address as Address, networkId: v.networkId })), + userAddress, + ) + : Promise.resolve(new Map()), + ]); - // Step 3: Combine Monarch vault metadata with balances and supplemental APY + // Combine Monarch vault metadata with optional balances and supplemental APY return validVaults.map((vault) => ({ ...vault, adapter: vault.adapters[0] as Address | undefined, - avgApy: avgApyByVault.get(`${vault.address.toLowerCase()}-${vault.networkId}`), - balance: shareBalances.get(vault.address.toLowerCase()) ?? 0n, + avgApy: avgApyByVault.get(getVaultQueryKey(vault.address, vault.networkId)), + balance: shareBalances.get(getVaultQueryKey(vault.address, vault.networkId)) ?? 0n, })); } @@ -67,18 +83,20 @@ async function fetchAndProcessVaults(userAddress: Address): Promise { const { address: connectedAddress } = useConnection(); + const includeApy = options.includeApy ?? true; + const includeBalances = options.includeBalances ?? true; const userAddress = (options.userAddress ?? connectedAddress) as Address; const enabled = options.enabled ?? true; return useQuery({ - queryKey: ['user-vaults-v2', userAddress], + queryKey: ['user-vaults-v2', userAddress, { includeApy, includeBalances }], queryFn: async () => { if (!userAddress) { return []; } try { - return await fetchAndProcessVaults(userAddress); + return await fetchAndProcessVaults({ includeApy, includeBalances, userAddress }); } catch (err) { const fetchError = err instanceof Error ? err : new Error('Failed to fetch user vaults'); console.error('Error fetching user V2 vaults:', fetchError); diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index c92a54b6..3e4887f6 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { type Address, encodeFunctionData, zeroAddress, toFunctionSelector } from 'viem'; +import { type Address, encodeFunctionData, zeroAddress } from 'viem'; import { useQueryClient } from '@tanstack/react-query'; import { useConnection, useChainId, useReadContracts } from 'wagmi'; import { vaultv2Abi } from '@/abis/vaultv2'; @@ -234,8 +234,8 @@ export function useVaultV2({ // All morpho v2 vault operations have to be proposed first, and then execute const completeInitialization = useCallback( - async (morphoRegistry: Address, marketV1Adapter: Address, allocator?: Address, _name?: string, _symbol?: string): Promise => { - if (!account || !vaultAddress || marketV1Adapter === zeroAddress) return false; + async (morphoRegistry: Address, marketAdapter: Address, allocator?: Address, _name?: string, _symbol?: string): Promise => { + if (!account || !vaultAddress || marketAdapter === zeroAddress) return false; const txs: `0x${string}`[] = []; @@ -287,7 +287,7 @@ export function useVaultV2({ const addAdapterTx = encodeFunctionData({ abi: vaultv2Abi, functionName: 'addAdapter', - args: [marketV1Adapter], + args: [marketAdapter], }); const submitAddAdapterTx = encodeFunctionData({ @@ -303,21 +303,21 @@ export function useVaultV2({ // Note: do not do this for maximized flexibility for now: open in the future! // Step 5. Abdicate registry control. - const setAdapterRegistrySelector = toFunctionSelector('setAdapterRegistry(address)'); + // const setAdapterRegistrySelector = toFunctionSelector('setAdapterRegistry(address)'); - const abdicateSetAdapterRegistryTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'abdicate', - args: [setAdapterRegistrySelector], - }); + // const abdicateSetAdapterRegistryTx = encodeFunctionData({ + // abi: vaultv2Abi, + // functionName: 'abdicate', + // args: [setAdapterRegistrySelector], + // }); - const submitAbdicateSetAdapterRegistryTx = encodeFunctionData({ - abi: vaultv2Abi, - functionName: 'submit', - args: [abdicateSetAdapterRegistryTx], - }); + // const submitAbdicateSetAdapterRegistryTx = encodeFunctionData({ + // abi: vaultv2Abi, + // functionName: 'submit', + // args: [abdicateSetAdapterRegistryTx], + // }); - txs.push(submitAbdicateSetAdapterRegistryTx, abdicateSetAdapterRegistryTx); + // txs.push(submitAbdicateSetAdapterRegistryTx, abdicateSetAdapterRegistryTx); // Step 6.1 Set user as allocator (for withdrawal / setting Withdrawal Data) const setSelfAllocatorTx = encodeFunctionData({ diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index f8b79af2..08d42aac 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -12,8 +12,6 @@ import { getClient } from '@/utils/rpc'; type UseVaultV2DataArgs = { vaultAddress?: Address; chainId: SupportedNetworks; - fallbackName?: string; - fallbackSymbol?: string; }; export type CapData = { @@ -99,7 +97,7 @@ const fetchBasicVaultRpcData = async (vaultAddress: Address, chainId: SupportedN }; }; -export function useVaultV2Data({ vaultAddress, chainId, fallbackName = '', fallbackSymbol = '' }: UseVaultV2DataArgs) { +export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { const { findToken } = useTokensQuery(); const query = useQuery({ @@ -152,8 +150,8 @@ export function useVaultV2Data({ vaultAddress, chainId, fallbackName = '', fallb const curator = monarchVault?.curator || rpcFallback?.curator || ''; return { - displayName: monarchVault?.name || rpcFallback?.displayName || fallbackName, - displaySymbol: monarchVault?.symbol || rpcFallback?.displaySymbol || fallbackSymbol, + displayName: monarchVault?.name || rpcFallback?.displayName || '', + displaySymbol: monarchVault?.symbol || rpcFallback?.displaySymbol || '', assetAddress, tokenSymbol, tokenDecimals, diff --git a/src/utils/vaultAllocation.ts b/src/utils/vaultAllocation.ts index 869e7562..9f3a0717 100644 --- a/src/utils/vaultAllocation.ts +++ b/src/utils/vaultAllocation.ts @@ -3,6 +3,8 @@ import { vaultv2Abi } from '@/abis/vaultv2'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; +const getVaultBalanceKey = (address: Address | string, networkId: SupportedNetworks) => `${address.toLowerCase()}-${networkId}`; + /** * Calculate allocation percentage relative to total */ @@ -79,7 +81,7 @@ export async function fetchUserVaultShares( if (redeemContracts.length === 0) { // No vaults with balance, return zeros vaultAddresses.forEach((addr) => { - results.set(addr.toLowerCase(), 0n); + results.set(getVaultBalanceKey(addr, networkId), 0n); }); return; } @@ -98,7 +100,7 @@ export async function fetchUserVaultShares( redeemContracts.forEach((contract, index) => { if (contract) { const result = redeemResults[index]; - const vaultAddress = contract._vaultAddress.toLowerCase(); + const vaultAddress = getVaultBalanceKey(contract._vaultAddress, networkId); if (result.status === 'success' && result.result) { results.set(vaultAddress, result.result as bigint); } else { @@ -109,15 +111,16 @@ export async function fetchUserVaultShares( // Set 0 for vaults that had 0 balance vaultAddresses.forEach((addr) => { - if (!results.has(addr.toLowerCase())) { - results.set(addr.toLowerCase(), 0n); + const vaultKey = getVaultBalanceKey(addr, networkId); + if (!results.has(vaultKey)) { + results.set(vaultKey, 0n); } }); } catch (error) { console.error(`Failed to fetch vault shares for network ${networkId}:`, error); // Set all to 0 on error vaultAddresses.forEach((addr) => { - results.set(addr.toLowerCase(), 0n); + results.set(getVaultBalanceKey(addr, networkId), 0n); }); } }), From 0d33f95c9840cc4a17bd90e148d3d10bf6f26a49 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 20:02:10 +0800 Subject: [PATCH 4/7] chore: lint --- src/data-sources/monarch-api/fetchers.ts | 6 +- src/data-sources/monarch-api/vaults.ts | 5 +- .../modals/vault-initialization-modal.tsx | 2 +- .../vault-settings/VaultSettingsHeader.tsx | 4 +- .../details/EditMetadataDetail.tsx | 6 +- .../vault-settings/panels/GeneralPanel.tsx | 8 +- src/features/autovault/vault-list-view.tsx | 118 +++++++----------- src/hooks/queries/useUserVaultsV2Query.ts | 27 ++-- src/hooks/useDeployMorphoMarketAdapter.ts | 8 +- src/hooks/useVaultV2.ts | 28 +---- src/hooks/useVaultV2Data.ts | 12 +- src/utils/vaultAllocation.ts | 69 +++++++--- 12 files changed, 146 insertions(+), 147 deletions(-) diff --git a/src/data-sources/monarch-api/fetchers.ts b/src/data-sources/monarch-api/fetchers.ts index 712e138d..0bb15df3 100644 --- a/src/data-sources/monarch-api/fetchers.ts +++ b/src/data-sources/monarch-api/fetchers.ts @@ -33,7 +33,11 @@ export const monarchGraphqlFetcher = async >( const result = (await response.json()) as T & { errors?: GraphQLError[] }; if (result.errors && result.errors.length > 0) { - const message = result.errors.map((error) => error.message).filter(Boolean).join('; ') || 'Unknown Monarch GraphQL error'; + const message = + result.errors + .map((error) => error.message) + .filter(Boolean) + .join('; ') || 'Unknown Monarch GraphQL error'; throw new Error(message); } diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts index 010ff752..24e34987 100644 --- a/src/data-sources/monarch-api/vaults.ts +++ b/src/data-sources/monarch-api/vaults.ts @@ -36,6 +36,7 @@ export type VaultV2Details = { export type UserVaultV2 = VaultV2Details & { networkId: SupportedNetworks; balance?: bigint; + totalAssets?: bigint; adapter?: Address; actualApy?: number; }; @@ -159,7 +160,9 @@ const transformVault = (vault: MonarchVault, adapterDetails: VaultAdapterDetails } const activeAdapters = vault.adapters.filter((adapter) => adapter.isActive).map((adapter) => normalizeAddress(adapter.adapterAddress)); - const activeAllocators = vault.allocators.filter((allocator) => allocator.isAllocator).map((allocator) => normalizeAddress(allocator.account)); + const activeAllocators = vault.allocators + .filter((allocator) => allocator.isAllocator) + .map((allocator) => normalizeAddress(allocator.account)); const activeSentinels = vault.sentinels.filter((sentinel) => sentinel.isSentinel).map((sentinel) => normalizeAddress(sentinel.account)); return { diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index 8ed74a67..2405cd59 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -247,7 +247,7 @@ export function VaultInitializationModal() { }, [chainId]); // Adapter is detected if Monarch has indexed it or we just deployed it locally. - const adapterAddress = deployedAdapter !== ZERO_ADDRESS ? deployedAdapter : (marketAdapter ?? ZERO_ADDRESS); + const adapterAddress = deployedAdapter === ZERO_ADDRESS ? (marketAdapter ?? ZERO_ADDRESS) : deployedAdapter; const adapterDetected = adapterAddress !== ZERO_ADDRESS; const { deploy, isDeploying, canDeploy, factoryAddress } = useDeployMorphoMarketAdapter({ diff --git a/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsHeader.tsx b/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsHeader.tsx index 88c5969c..08eb78af 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsHeader.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-settings/VaultSettingsHeader.tsx @@ -22,7 +22,7 @@ export function VaultSettingsHeader({ vaultAddress, chainId, detailView, onBack, return ( void refetch({ includeRetries: true })} @@ -32,7 +32,7 @@ export function VaultSettingsHeader({ vaultAddress, chainId, detailView, onBack, > - )} + } title={title} showBack={!!detailView} onBack={onBack} diff --git a/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditMetadataDetail.tsx b/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditMetadataDetail.tsx index da2fb549..a7f61574 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditMetadataDetail.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-settings/details/EditMetadataDetail.tsx @@ -17,7 +17,7 @@ export function EditMetadataDetail({ vaultAddress, chainId, onBack }: EditMetada const { address: connectedAddress } = useConnection(); const { data: vaultData } = useVaultV2Data({ vaultAddress, chainId }); - const { isOwner, name, symbol, updateNameAndSymbol, isUpdatingMetadata } = useVaultV2({ + const { isOwner, updateNameAndSymbol, isUpdatingMetadata } = useVaultV2({ vaultAddress, chainId, connectedAddress, @@ -34,8 +34,8 @@ export function EditMetadataDetail({ vaultAddress, chainId, onBack }: EditMetada isUpdating={isUpdatingMetadata} defaultName={defaultName} defaultSymbol={defaultSymbol} - currentName={name} - currentSymbol={symbol} + currentName={defaultName} + currentSymbol={defaultSymbol} onUpdate={(newName, newSymbol) => updateNameAndSymbol({ name: newName, symbol: newSymbol })} onBack={onBack} /> diff --git a/src/features/autovault/components/vault-detail/modals/vault-settings/panels/GeneralPanel.tsx b/src/features/autovault/components/vault-detail/modals/vault-settings/panels/GeneralPanel.tsx index f77de024..040ebaaa 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-settings/panels/GeneralPanel.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-settings/panels/GeneralPanel.tsx @@ -19,7 +19,7 @@ export function GeneralPanel({ vaultAddress, chainId, onNavigateToDetail }: Gene // Pull data directly - TanStack Query deduplicates const { data: vaultData } = useVaultV2Data({ vaultAddress, chainId }); - const { isOwner, name, symbol } = useVaultV2({ + const { isOwner } = useVaultV2({ vaultAddress, chainId, connectedAddress, @@ -27,8 +27,6 @@ export function GeneralPanel({ vaultAddress, chainId, onNavigateToDetail }: Gene const defaultName = vaultData?.displayName ?? ''; const defaultSymbol = vaultData?.displaySymbol ?? ''; - const currentName = name !== '' ? name : defaultName; - const currentSymbol = symbol !== '' ? symbol : defaultSymbol; return (
@@ -52,12 +50,12 @@ export function GeneralPanel({ vaultAddress, chainId, onNavigateToDetail }: Gene

Vault Name

-

{currentName}

+

{defaultName || '--'}

Vault Symbol

-

{currentSymbol}

+

{defaultSymbol || '--'}

diff --git a/src/features/autovault/vault-list-view.tsx b/src/features/autovault/vault-list-view.tsx index a69d9787..e028d28f 100644 --- a/src/features/autovault/vault-list-view.tsx +++ b/src/features/autovault/vault-list-view.tsx @@ -7,15 +7,15 @@ import { GearIcon } from '@radix-ui/react-icons'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { useRouter } from 'next/navigation'; import { useAppKit } from '@reown/appkit/react'; -import { type Address } from 'viem'; +import type { Address } from 'viem'; import { useConnection } from 'wagmi'; import { Button } from '@/components/ui/button'; import { Avatar } from '@/components/Avatar/Avatar'; -import { TokenIcon } from '@/components/shared/token-icon'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import Header from '@/components/layout/header/Header'; import { useUserVaultsV2Query } from '@/hooks/queries/useUserVaultsV2Query'; import { useTokensQuery } from '@/hooks/queries/useTokensQuery'; +import { formatCompactTokenAmount } from '@/utils/token-amount-format'; import { getNetworkName } from '@/utils/networks'; import { getDeployedVaults } from '@/utils/vault-storage'; import { DeploymentModal } from './components/deployment/deployment-modal'; @@ -27,6 +27,7 @@ type VaultListItem = { name?: string; networkId: number; symbol?: string; + totalAssets?: bigint; }; const getVaultLabel = (vault: VaultListItem): string => { @@ -78,6 +79,7 @@ export default function AutovaultListContent() { enabled: hasMounted && isConnected && Boolean(address), includeApy: false, includeBalances: false, + includeTotalAssets: true, }); const handleConnect = () => { @@ -100,6 +102,7 @@ export default function AutovaultListContent() { name: vault.name, networkId: vault.networkId, symbol: vault.symbol, + totalAssets: vault.totalAssets, })); const existingVaults = new Set(combined.map((vault) => `${vault.address.toLowerCase()}-${vault.networkId}`)); @@ -119,13 +122,8 @@ export default function AutovaultListContent() { return combined; }, [address, userVaultsQuery.data]); - const handleManageVault = (vaultAddress?: string, networkId?: number) => { - if (vaultAddress && networkId) { - router.push(`/autovault/${networkId}/${vaultAddress}`); - } else if (mergedVaults.length > 0) { - const firstVault = mergedVaults[0]; - router.push(`/autovault/${firstVault.networkId}/${firstVault.address}`); - } + const handleManageVault = (vaultAddress: string, networkId: number) => { + router.push(`/autovault/${networkId}/${vaultAddress}`); }; const hasVaults = mergedVaults.length > 0; @@ -133,6 +131,24 @@ export default function AutovaultListContent() { const hasMultipleVaults = mergedVaults.length > 1; const fetchError = userVaultsQuery.error ? 'Unable to load vaults. Please try again.' : null; const vaultsLoading = userVaultsQuery.isLoading; + const primaryVault = mergedVaults[0] ?? null; + + const getVaultSecondaryLabel = (vault: VaultListItem): string => { + const token = vault.asset ? findToken(vault.asset, vault.networkId) : undefined; + const amountLabel = + token && vault.totalAssets !== undefined ? `${formatCompactTokenAmount(vault.totalAssets, token.decimals)} ${token.symbol}` : null; + const networkLabel = getNetworkName(vault.networkId) ?? `Chain ${vault.networkId}`; + + if (amountLabel) { + return `${amountLabel} · ${networkLabel}`; + } + + if (token?.symbol) { + return `${token.symbol} · ${networkLabel}`; + } + + return `${networkLabel} · Indexing`; + }; return (
@@ -184,39 +200,48 @@ export default function AutovaultListContent() { {isConnected && hasVaults && (
{/* Single vault - show avatar with address */} - {hasSingleVault && ( + {hasSingleVault && primaryVault && ( )} {/* Multiple vaults - show dropdown */} - {hasMultipleVaults && ( + {hasMultipleVaults && primaryVault && ( - + {mergedVaults.map((vault) => ( } > -
- {getVaultLabel(vault)} - {getNetworkName(vault.networkId) ?? `Chain ${vault.networkId}`} +
+ {getVaultLabel(vault)} + {getVaultSecondaryLabel(vault)}
))} @@ -284,55 +309,6 @@ export default function AutovaultListContent() { )}
- {isConnected && hasVaults && ( -
- {mergedVaults.map((vault) => { - const assetToken = vault.asset ? findToken(vault.asset, vault.networkId) : undefined; - const displayName = getVaultLabel(vault); - const displaySymbol = vault.symbol?.trim(); - const isIndexed = Boolean(vault.name || vault.symbol || vault.asset); - - return ( - - ); - })} -
- )} - {/* Benefits Section */}
vault.owner && vault.asset && vault.address); } -const getVaultQueryKey = (address: string, networkId: number) => `${address.toLowerCase()}-${networkId}`; - async function fetchAndProcessVaults({ includeApy, includeBalances, + includeTotalAssets, userAddress, }: { includeApy: boolean; includeBalances: boolean; + includeTotalAssets: boolean; userAddress: Address; }): Promise { const validVaults = filterValidVaults(await fetchUserVaultV2DetailsAllNetworks(userAddress)); @@ -33,7 +34,7 @@ async function fetchAndProcessVaults({ return []; } - const [avgApyByVault, shareBalances] = await Promise.all([ + const [avgApyByVault, shareBalances, totalAssetsByVault] = await Promise.all([ includeApy ? fetchMorphoVaultApys( validVaults.map((vault) => ({ @@ -48,14 +49,18 @@ async function fetchAndProcessVaults({ userAddress, ) : Promise.resolve(new Map()), + includeTotalAssets + ? fetchVaultTotalAssets(validVaults.map((v) => ({ address: v.address as Address, networkId: v.networkId }))) + : Promise.resolve(new Map()), ]); // Combine Monarch vault metadata with optional balances and supplemental APY return validVaults.map((vault) => ({ ...vault, adapter: vault.adapters[0] as Address | undefined, - avgApy: avgApyByVault.get(getVaultQueryKey(vault.address, vault.networkId)), - balance: shareBalances.get(getVaultQueryKey(vault.address, vault.networkId)) ?? 0n, + avgApy: avgApyByVault.get(getVaultReadKey(vault.address, vault.networkId)), + balance: shareBalances.get(getVaultReadKey(vault.address, vault.networkId)) ?? 0n, + totalAssets: totalAssetsByVault.get(getVaultReadKey(vault.address, vault.networkId)), })); } @@ -65,8 +70,9 @@ async function fetchAndProcessVaults({ * Data fetching strategy: * - Fetches cross-chain vault details from Monarch API * - Optionally enriches current APY from batched Morpho API vault rates - * - Fetches user's share balances via multicall - * - Returns complete vault data with balances + * - Optionally enriches user's share balances via multicall + * - Optionally enriches vault total assets via multicall + * - Returns complete vault data with optional on-chain enrichments * * Cache behavior: * - staleTime: 60 seconds (complex multi-step fetch) @@ -85,18 +91,19 @@ export const useUserVaultsV2Query = (options: UseUserVaultsV2Options = {}) => { const includeApy = options.includeApy ?? true; const includeBalances = options.includeBalances ?? true; + const includeTotalAssets = options.includeTotalAssets ?? false; const userAddress = (options.userAddress ?? connectedAddress) as Address; const enabled = options.enabled ?? true; return useQuery({ - queryKey: ['user-vaults-v2', userAddress, { includeApy, includeBalances }], + queryKey: ['user-vaults-v2', userAddress, { includeApy, includeBalances, includeTotalAssets }], queryFn: async () => { if (!userAddress) { return []; } try { - return await fetchAndProcessVaults({ includeApy, includeBalances, userAddress }); + return await fetchAndProcessVaults({ includeApy, includeBalances, includeTotalAssets, userAddress }); } catch (err) { const fetchError = err instanceof Error ? err : new Error('Failed to fetch user vaults'); console.error('Error fetching user V2 vaults:', fetchError); diff --git a/src/hooks/useDeployMorphoMarketAdapter.ts b/src/hooks/useDeployMorphoMarketAdapter.ts index 821f2d41..7bc5e958 100644 --- a/src/hooks/useDeployMorphoMarketAdapter.ts +++ b/src/hooks/useDeployMorphoMarketAdapter.ts @@ -7,13 +7,7 @@ import { useTransactionWithToast } from './useTransactionWithToast'; const TX_TOAST_ID = 'deploy-morpho-market-adapter'; -export function useDeployMorphoMarketAdapter({ - vaultAddress, - chainId, -}: { - vaultAddress?: Address; - chainId?: SupportedNetworks | number; -}) { +export function useDeployMorphoMarketAdapter({ vaultAddress, chainId }: { vaultAddress?: Address; chainId?: SupportedNetworks | number }) { const { address: account } = useConnection(); const connectedChainId = useChainId(); const resolvedChainId = (chainId ?? connectedChainId) as SupportedNetworks; diff --git a/src/hooks/useVaultV2.ts b/src/hooks/useVaultV2.ts index 3e4887f6..f8113f38 100644 --- a/src/hooks/useVaultV2.ts +++ b/src/hooks/useVaultV2.ts @@ -100,18 +100,6 @@ export function useVaultV2({ functionName: 'curator', args: [], }, - { - // name - ...vaultContract, - functionName: 'name', - args: [], - }, - { - // symbol - ...vaultContract, - functionName: 'symbol', - args: [], - }, { // totalAssets ...vaultContract, @@ -136,15 +124,13 @@ export function useVaultV2({ }, }); - const [owner, curator, name, symbol, totalAssets, userShares, totalSupply] = useMemo(() => { + const [owner, curator, totalAssets, userShares, totalSupply] = useMemo(() => { return [ batchData?.[0].result ?? zeroAddress, batchData?.[1].result ?? zeroAddress, - batchData?.[2].result ?? '', - batchData?.[3].result ?? '', + batchData?.[2].result ?? 0n, + batchData?.[3].result ?? 0n, batchData?.[4].result ?? 0n, - batchData?.[5].result ?? 0n, - batchData?.[6].result ?? 0n, ]; }, [batchData]); @@ -503,7 +489,7 @@ export function useVaultV2({ throw allocatorError; } }, - [account, chainIdToUse, sendAllocatorTx, vaultAddress, queryClient], + [account, chainIdToUse, sendAllocatorTx, vaultAddress], ); const swapAllocator = useCallback( @@ -570,7 +556,7 @@ export function useVaultV2({ throw swapError; } }, - [account, chainIdToUse, sendSwapAllocatorTx, vaultAddress, queryClient], + [account, chainIdToUse, sendSwapAllocatorTx, vaultAddress], ); const updateCaps = useCallback( @@ -681,7 +667,7 @@ export function useVaultV2({ throw capsError; } }, - [account, chainIdToUse, sendCapsTx, vaultAddress, queryClient], + [account, chainIdToUse, sendCapsTx, vaultAddress], ); const { isConfirming: isDepositing, sendTransactionAsync: sendDepositTx } = useTransactionWithToast({ @@ -852,8 +838,6 @@ export function useVaultV2({ refetch: refetchAll, completeInitialization, isInitializing, - name, - symbol, owner, isOwner, updateNameAndSymbol, diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 08d42aac..031a3f0e 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -41,20 +41,16 @@ type BasicVaultRpcData = { assetAddress: string; adapters: string[]; curator: string; - displayName: string; - displaySymbol: string; owner: string; }; const fetchBasicVaultRpcData = async (vaultAddress: Address, chainId: SupportedNetworks): Promise => { const client = getClient(chainId); const contractBase = { address: vaultAddress, abi: vaultv2Abi } as const; - const [owner, curator, name, symbol, asset] = await client.multicall({ + const [owner, curator, asset] = await client.multicall({ contracts: [ { ...contractBase, functionName: 'owner', args: [] }, { ...contractBase, functionName: 'curator', args: [] }, - { ...contractBase, functionName: 'name', args: [] }, - { ...contractBase, functionName: 'symbol', args: [] }, { ...contractBase, functionName: 'asset', args: [] }, ], allowFailure: true, @@ -91,8 +87,6 @@ const fetchBasicVaultRpcData = async (vaultAddress: Address, chainId: SupportedN adapters, owner: owner.status === 'success' && owner.result !== zeroAddress ? owner.result : '', curator: curator.status === 'success' && curator.result !== zeroAddress ? curator.result : '', - displayName: name.status === 'success' ? name.result : '', - displaySymbol: symbol.status === 'success' ? symbol.result : '', assetAddress: asset.status === 'success' && asset.result !== zeroAddress ? asset.result : '', }; }; @@ -150,8 +144,8 @@ export function useVaultV2Data({ vaultAddress, chainId }: UseVaultV2DataArgs) { const curator = monarchVault?.curator || rpcFallback?.curator || ''; return { - displayName: monarchVault?.name || rpcFallback?.displayName || '', - displaySymbol: monarchVault?.symbol || rpcFallback?.displaySymbol || '', + displayName: monarchVault?.name || '', + displaySymbol: monarchVault?.symbol || '', assetAddress, tokenSymbol, tokenDecimals, diff --git a/src/utils/vaultAllocation.ts b/src/utils/vaultAllocation.ts index 9f3a0717..ea004e3f 100644 --- a/src/utils/vaultAllocation.ts +++ b/src/utils/vaultAllocation.ts @@ -3,7 +3,7 @@ import { vaultv2Abi } from '@/abis/vaultv2'; import type { SupportedNetworks } from '@/utils/networks'; import { getClient } from '@/utils/rpc'; -const getVaultBalanceKey = (address: Address | string, networkId: SupportedNetworks) => `${address.toLowerCase()}-${networkId}`; +export const getVaultReadKey = (address: Address | string, networkId: SupportedNetworks) => `${address.toLowerCase()}-${networkId}`; /** * Calculate allocation percentage relative to total @@ -14,6 +14,19 @@ export function calculateAllocationPercent(amount: bigint, total: bigint): strin return percent.toFixed(2); } +const groupVaultsByNetwork = (vaults: { address: Address; networkId: SupportedNetworks }[]): Record => { + return vaults.reduce( + (acc, vault) => { + if (!acc[vault.networkId]) { + acc[vault.networkId] = []; + } + acc[vault.networkId].push(vault.address); + return acc; + }, + {} as Record, + ); +}; + /** * Batch fetch user's vault shares and convert to redeemable assets * @param vaults - Array of vaults with address and networkId @@ -25,16 +38,7 @@ export async function fetchUserVaultShares( userAddress: Address, ): Promise> { // Group vaults by network for efficient batching - const vaultsByNetwork = vaults.reduce( - (acc, vault) => { - if (!acc[vault.networkId]) { - acc[vault.networkId] = []; - } - acc[vault.networkId].push(vault.address); - return acc; - }, - {} as Record, - ); + const vaultsByNetwork = groupVaultsByNetwork(vaults); const results = new Map(); @@ -81,7 +85,7 @@ export async function fetchUserVaultShares( if (redeemContracts.length === 0) { // No vaults with balance, return zeros vaultAddresses.forEach((addr) => { - results.set(getVaultBalanceKey(addr, networkId), 0n); + results.set(getVaultReadKey(addr, networkId), 0n); }); return; } @@ -100,7 +104,7 @@ export async function fetchUserVaultShares( redeemContracts.forEach((contract, index) => { if (contract) { const result = redeemResults[index]; - const vaultAddress = getVaultBalanceKey(contract._vaultAddress, networkId); + const vaultAddress = getVaultReadKey(contract._vaultAddress, networkId); if (result.status === 'success' && result.result) { results.set(vaultAddress, result.result as bigint); } else { @@ -111,7 +115,7 @@ export async function fetchUserVaultShares( // Set 0 for vaults that had 0 balance vaultAddresses.forEach((addr) => { - const vaultKey = getVaultBalanceKey(addr, networkId); + const vaultKey = getVaultReadKey(addr, networkId); if (!results.has(vaultKey)) { results.set(vaultKey, 0n); } @@ -120,7 +124,7 @@ export async function fetchUserVaultShares( console.error(`Failed to fetch vault shares for network ${networkId}:`, error); // Set all to 0 on error vaultAddresses.forEach((addr) => { - results.set(getVaultBalanceKey(addr, networkId), 0n); + results.set(getVaultReadKey(addr, networkId), 0n); }); } }), @@ -128,3 +132,38 @@ export async function fetchUserVaultShares( return results; } + +export async function fetchVaultTotalAssets(vaults: { address: Address; networkId: SupportedNetworks }[]): Promise> { + const vaultsByNetwork = groupVaultsByNetwork(vaults); + const results = new Map(); + + await Promise.all( + Object.entries(vaultsByNetwork).map(async ([networkIdStr, vaultAddresses]) => { + const networkId = Number(networkIdStr) as SupportedNetworks; + const client = getClient(networkId); + + try { + const totalAssetsResults = await client.multicall({ + contracts: vaultAddresses.map((vaultAddress) => ({ + address: vaultAddress, + abi: vaultv2Abi, + functionName: 'totalAssets' as const, + args: [], + })), + allowFailure: true, + }); + + vaultAddresses.forEach((vaultAddress, index) => { + const result = totalAssetsResults[index]; + if (result.status === 'success' && typeof result.result === 'bigint') { + results.set(getVaultReadKey(vaultAddress, networkId), result.result); + } + }); + } catch (error) { + console.error(`Failed to fetch vault total assets for network ${networkId}:`, error); + } + }), + ); + + return results; +} From 396092d7682e2cde1ae9d0cf1a4203a218ffc077 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 21:06:27 +0800 Subject: [PATCH 5/7] chore: lint --- AGENTS.md | 2 + app/api/monarch/graphql/route.ts | 19 ++++++--- app/api/monarch/metrics/route.ts | 19 ++++++--- app/api/monarch/utils.ts | 41 +++++++++++++++++-- src/data-sources/monarch-api/vaults.ts | 2 +- src/data-sources/morpho-api/vaults.ts | 8 +++- .../modals/vault-initialization-modal.tsx | 6 ++- .../vault-settings/panels/RolesPanel.tsx | 3 +- src/features/autovault/vault-list-view.tsx | 4 +- src/features/autovault/vault-view.tsx | 14 +++---- .../components/charts/volume-chart.tsx | 4 +- src/hooks/queries/useUserVaultsV2Query.ts | 18 ++++---- src/hooks/useDeployMorphoMarketAdapter.ts | 2 +- src/hooks/useMorphoMarketAdapters.ts | 12 ++++-- src/hooks/useVaultPage.ts | 26 +++++++----- src/hooks/useVaultQueryRefresh.ts | 27 ++++++++---- src/hooks/useVaultV2Data.ts | 32 +++++++++------ src/modals/vault/vault-withdraw-modal.tsx | 9 +++- src/utils/transaction-errors.ts | 19 +++++---- 19 files changed, 182 insertions(+), 85 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index abc08e9c..eac74795 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,8 @@ When touching transaction and position flows, validation MUST include all releva 32. **Canonical vault-metadata source integrity**: when Monarch API exposes chain-scoped V2 vault metadata (owner, curator, asset, allocators, sentinels, adapters, caps), use it as the primary read chokepoint. Do not rebuild the same view through per-chain subgraphs, slow Morpho API fanout, local key caches, or event parsing except for narrow RPC fallback while a fresh write has not been indexed yet. 33. **Adapter-factory write integrity**: new market-adapter deployment must use the configured chain-scoped `MorphoMarketV1AdapterV2` factory directly, and post-tx adapter discovery should come from the V2 creation receipt/log rather than an extra factory read. Existing vault adapter type labels may come from Monarch API, but do not reintroduce V1 deployment assumptions or version-branching into the write path. 34. **Vault post-tx refresh integrity**: do not invalidate/refetch Monarch-backed vault queries before transaction confirmation. After confirmed vault writes that change Monarch-indexed fields (metadata, adapters, allocators, caps), trigger the shared vault-query refetch chokepoint with bounded follow-up retries so indexer lag does not leave the UI stale. +35. **Monarch proxy boundary integrity**: server-side Monarch proxy routes must use server-only API key env vars and bounded `AbortController` timeouts on upstream fetches. Do not let Monarch GraphQL or metrics calls hang indefinitely, and do not reference `NEXT_PUBLIC_*` secrets in server authorization headers. +36. **Vault fallback and adapter-sentinel integrity**: treat `zeroAddress` as “no adapter” in all vault routing and transaction-critical paths, canonicalize vault query/cache identity by lowercase address plus chain, and when Monarch metadata is unavailable fail closed or return explicit unknown state instead of synthesizing empty vault data or assuming missing allocators/caps from absent indexed fields. ### REQUIRED: Regression Rule Capture diff --git a/app/api/monarch/graphql/route.ts b/app/api/monarch/graphql/route.ts index f9251062..bf92b5df 100644 --- a/app/api/monarch/graphql/route.ts +++ b/app/api/monarch/graphql/route.ts @@ -1,23 +1,28 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { MONARCH_GRAPHQL_API_KEY, getMonarchGraphqlUrl } from '../utils'; +import { + MONARCH_GRAPHQL_API_KEY, + MONARCH_GRAPHQL_TIMEOUT_MS, + fetchMonarchUpstream, + getMonarchGraphqlUrl, + getMonarchRouteFailure, +} from '../utils'; import { reportApiRouteError } from '@/utils/sentry-server'; export async function POST(request: NextRequest) { if (!MONARCH_GRAPHQL_API_KEY) { - console.error('[Monarch GraphQL API] Missing NEXT_PUBLIC_MONARCH_API_KEY'); + console.error('[Monarch GraphQL API] Missing MONARCH_GRAPHQL_API_KEY'); return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } try { const body = await request.json(); - const response = await fetch(getMonarchGraphqlUrl(), { + const response = await fetchMonarchUpstream(getMonarchGraphqlUrl(), MONARCH_GRAPHQL_TIMEOUT_MS, { method: 'POST', headers: { Authorization: `Bearer ${MONARCH_GRAPHQL_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), - cache: 'no-store', }); if (!response.ok) { @@ -28,12 +33,14 @@ export async function POST(request: NextRequest) { return NextResponse.json(await response.json()); } catch (error) { + const failure = getMonarchRouteFailure(error, 'Failed to fetch Monarch GraphQL data', 'Monarch GraphQL request timed out'); + reportApiRouteError(error, { route: '/api/monarch/graphql', method: 'POST', - status: 500, + status: failure.status, }); console.error('[Monarch GraphQL API] Failed to fetch:', error); - return NextResponse.json({ error: 'Failed to fetch Monarch GraphQL data' }, { status: 500 }); + return NextResponse.json({ error: failure.message }, { status: failure.status }); } } diff --git a/app/api/monarch/metrics/route.ts b/app/api/monarch/metrics/route.ts index ce661934..2c17e787 100644 --- a/app/api/monarch/metrics/route.ts +++ b/app/api/monarch/metrics/route.ts @@ -1,10 +1,16 @@ import { type NextRequest, NextResponse } from 'next/server'; -import { MONARCH_METRICS_API_KEY, getMonarchMetricsUrl } from '../utils'; +import { + MONARCH_METRICS_API_KEY, + MONARCH_METRICS_TIMEOUT_MS, + fetchMonarchUpstream, + getMonarchMetricsUrl, + getMonarchRouteFailure, +} from '../utils'; import { reportApiRouteError } from '@/utils/sentry-server'; export async function GET(req: NextRequest) { if (!MONARCH_METRICS_API_KEY) { - console.error('[Monarch Metrics API] Missing MONARCH_API_KEY'); + console.error('[Monarch Metrics API] Missing MONARCH_METRICS_API_KEY'); return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); } @@ -17,9 +23,8 @@ export async function GET(req: NextRequest) { if (value) url.searchParams.set(key, value); } - const response = await fetch(url, { + const response = await fetchMonarchUpstream(url, MONARCH_METRICS_TIMEOUT_MS, { headers: { 'X-API-Key': MONARCH_METRICS_API_KEY }, - cache: 'no-store', }); if (!response.ok) { @@ -30,12 +35,14 @@ export async function GET(req: NextRequest) { return NextResponse.json(await response.json()); } catch (error) { + const failure = getMonarchRouteFailure(error, 'Failed to fetch market metrics', 'Monarch metrics request timed out'); + reportApiRouteError(error, { route: '/api/monarch/metrics', method: 'GET', - status: 500, + status: failure.status, }); console.error('[Monarch Metrics API] Failed to fetch:', error); - return NextResponse.json({ error: 'Failed to fetch market metrics' }, { status: 500 }); + return NextResponse.json({ error: failure.message }, { status: failure.status }); } } diff --git a/app/api/monarch/utils.ts b/app/api/monarch/utils.ts index af3f2f6c..80434a3b 100644 --- a/app/api/monarch/utils.ts +++ b/app/api/monarch/utils.ts @@ -1,10 +1,43 @@ -export const MONARCH_METRICS_API_ENDPOINT = process.env.MONARCH_API_ENDPOINT; -export const MONARCH_METRICS_API_KEY = process.env.MONARCH_API_KEY; +export const MONARCH_METRICS_API_ENDPOINT = process.env.MONARCH_METRICS_API_ENDPOINT; +export const MONARCH_METRICS_API_KEY = process.env.MONARCH_METRICS_API_KEY; export const MONARCH_GRAPHQL_API_ENDPOINT = process.env.NEXT_PUBLIC_MONARCH_API_NEW; -export const MONARCH_GRAPHQL_API_KEY = process.env.NEXT_PUBLIC_MONARCH_API_KEY; +export const MONARCH_GRAPHQL_API_KEY = process.env.MONARCH_GRAPHQL_API_KEY; + +export const MONARCH_GRAPHQL_TIMEOUT_MS = 10_000; +export const MONARCH_METRICS_TIMEOUT_MS = 5_000; + +const isAbortError = (error: unknown): error is Error => error instanceof Error && error.name === 'AbortError'; + +export const getMonarchRouteFailure = ( + error: unknown, + fallbackMessage: string, + timeoutMessage: string, +): { + message: string; + status: number; +} => { + return isAbortError(error) ? { message: timeoutMessage, status: 504 } : { message: fallbackMessage, status: 500 }; +}; + +export const fetchMonarchUpstream = async (input: URL | string, timeoutMs: number, init: RequestInit): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + return await fetch(input, { + ...init, + cache: 'no-store', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +}; export const getMonarchMetricsUrl = (path: string): URL => { - if (!MONARCH_METRICS_API_ENDPOINT) throw new Error('MONARCH_API_ENDPOINT not configured'); + if (!MONARCH_METRICS_API_ENDPOINT) throw new Error('MONARCH_METRICS_API_ENDPOINT not configured'); return new URL(path, MONARCH_METRICS_API_ENDPOINT.replace(/\/$/, '')); }; diff --git a/src/data-sources/monarch-api/vaults.ts b/src/data-sources/monarch-api/vaults.ts index 24e34987..dec3b701 100644 --- a/src/data-sources/monarch-api/vaults.ts +++ b/src/data-sources/monarch-api/vaults.ts @@ -231,7 +231,7 @@ export const fetchMonarchVaultDetails = async (vaultAddress: string, chainId: Su ); const adapterDetails = (response.data?.Adapter ?? []) .map(transformAdapterRecord) - .filter((adapter) => activeAdapterAddresses.size === 0 || activeAdapterAddresses.has(adapter.address)); + .filter((adapter) => activeAdapterAddresses.has(adapter.address)); return transformVault(vault, adapterDetails); }; diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts index 94857d4e..9e1573b6 100644 --- a/src/data-sources/morpho-api/vaults.ts +++ b/src/data-sources/morpho-api/vaults.ts @@ -111,6 +111,8 @@ export const fetchMorphoVaultApys = async (vaults: VaultAddressByNetwork[]): Pro return new Map(); } + const requestedKeys = new Set(vaults.map((vault) => getVaultApyKey(vault.address, vault.networkId))); + try { const response = await morphoGraphqlFetcher(vaultApysQuery, { first: vaults.length, @@ -128,10 +130,14 @@ export const fetchMorphoVaultApys = async (vaults: VaultAddressByNetwork[]): Pro const apys = new Map(); for (const vault of items) { + const key = getVaultApyKey(vault.address, vault.chain.id); if (vault.avgApy === null || vault.avgApy === undefined) { continue; } - apys.set(getVaultApyKey(vault.address, vault.chain.id), vault.avgApy); + if (!requestedKeys.has(key)) { + continue; + } + apys.set(key, vault.avgApy); } return apys; diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index 2405cd59..f36c287b 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -265,6 +265,7 @@ export function VaultInitializationModal() { } const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }); + void refetchAdapter(); const createEvent = receipt.logs.find((log) => { if (log.address.toLowerCase() !== factoryAddress.toLowerCase()) { @@ -276,7 +277,6 @@ export function VaultInitializationModal() { if (createEvent && createEvent.topics[2]) { const adapter = `0x${createEvent.topics[2].slice(-40)}` as Address; setDeployedAdapter(adapter.toLowerCase() as Address); - void refetchAdapter(); setStepIndex(1); } } catch (_error) { @@ -344,6 +344,8 @@ export function VaultInitializationModal() { } }, [marketAdapter, stepIndex, deployedAdapter]); + const canCompleteInitialization = adapterAddress !== ZERO_ADDRESS && registryAddress !== ZERO_ADDRESS; + const stepTitle = useMemo(() => { switch (currentStep) { case 'deploy': @@ -414,7 +416,7 @@ export function VaultInitializationModal() {
diff --git a/src/utils/transaction-errors.ts b/src/utils/transaction-errors.ts index b780892d..38093cc7 100644 --- a/src/utils/transaction-errors.ts +++ b/src/utils/transaction-errors.ts @@ -20,8 +20,8 @@ type ErrorLike = { cause?: unknown; }; -const isErrorLike = (value: unknown): value is ErrorLike => { - return typeof value === 'object' && value !== null; +const asErrorLike = (value: unknown): ErrorLike | null => { + return typeof value === 'object' && value !== null ? (value as ErrorLike) : null; }; const asNonEmptyString = (value: unknown): string | null => { @@ -47,14 +47,19 @@ const sanitizeViemErrorMessage = (message: string): string => { const collectErrorChain = (error: unknown): ErrorLike[] => { const chain: ErrorLike[] = []; - const visited = new Set(); + const visited = new Set(); let current: unknown = error; let depth = 0; - while (isErrorLike(current) && !visited.has(current) && depth < ERROR_CAUSE_MAX_DEPTH) { - chain.push(current); - visited.add(current); - current = current.cause; + while (depth < ERROR_CAUSE_MAX_DEPTH) { + const errorRecord = asErrorLike(current); + if (!errorRecord || visited.has(errorRecord)) { + break; + } + + chain.push(errorRecord); + visited.add(errorRecord); + current = errorRecord.cause; depth += 1; } From 0d5e1dd24a887e6c23ae06f4346c19d884c2509a Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 21:36:07 +0800 Subject: [PATCH 6/7] chore: review fixes --- AGENTS.md | 1 + .../modals/vault-initialization-modal.tsx | 2 +- src/hooks/queries/useAllocations.ts | 10 +++-- src/hooks/useMorphoMarketAdapters.ts | 38 ++++++++++--------- src/hooks/useVaultPage.ts | 20 +++++++--- src/hooks/useVaultQueryRefresh.ts | 4 +- src/hooks/useVaultV2Data.ts | 9 ++++- 7 files changed, 53 insertions(+), 31 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eac74795..bbdeabbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,7 @@ When touching transaction and position flows, validation MUST include all releva 34. **Vault post-tx refresh integrity**: do not invalidate/refetch Monarch-backed vault queries before transaction confirmation. After confirmed vault writes that change Monarch-indexed fields (metadata, adapters, allocators, caps), trigger the shared vault-query refetch chokepoint with bounded follow-up retries so indexer lag does not leave the UI stale. 35. **Monarch proxy boundary integrity**: server-side Monarch proxy routes must use server-only API key env vars and bounded `AbortController` timeouts on upstream fetches. Do not let Monarch GraphQL or metrics calls hang indefinitely, and do not reference `NEXT_PUBLIC_*` secrets in server authorization headers. 36. **Vault fallback and adapter-sentinel integrity**: treat `zeroAddress` as “no adapter” in all vault routing and transaction-critical paths, canonicalize vault query/cache identity by lowercase address plus chain, and when Monarch metadata is unavailable fail closed or return explicit unknown state instead of synthesizing empty vault data or assuming missing allocators/caps from absent indexed fields. +37. **Vault setup-state derivation integrity**: only infer “adapter missing”, “needs initialization”, or auto-advance setup states from resolved adapter/vault queries, never from undefined/loading/error values. When Monarch returns both active adapter addresses and adapter detail rows, merge the union by canonical address instead of letting one source replace the other. ### REQUIRED: Regression Rule Capture diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index f36c287b..64c37955 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -339,7 +339,7 @@ export function VaultInitializationModal() { // Auto-advance when adapter already exists in Monarch data. useEffect(() => { - if (marketAdapter !== ZERO_ADDRESS && stepIndex === 0 && deployedAdapter === ZERO_ADDRESS) { + if (marketAdapter != null && marketAdapter !== ZERO_ADDRESS && stepIndex === 0 && deployedAdapter === ZERO_ADDRESS) { setStepIndex(1); } }, [marketAdapter, stepIndex, deployedAdapter]); diff --git a/src/hooks/queries/useAllocations.ts b/src/hooks/queries/useAllocations.ts index 36e3af33..7e3638e6 100644 --- a/src/hooks/queries/useAllocations.ts +++ b/src/hooks/queries/useAllocations.ts @@ -20,6 +20,8 @@ type UseAllocationsArgs = { }; export function useAllocationsQuery({ vaultAddress, chainId, caps = [], enabled = true }: UseAllocationsArgs) { + const normalizedVaultAddress = vaultAddress?.toLowerCase() as Address | undefined; + // Create a stable key from capIds to detect actual changes const capsKey = useMemo(() => { return caps @@ -29,15 +31,15 @@ export function useAllocationsQuery({ vaultAddress, chainId, caps = [], enabled }, [caps]); const query = useQuery({ - queryKey: ['vault-allocations', vaultAddress, chainId, capsKey], + queryKey: ['vault-allocations', normalizedVaultAddress, chainId, capsKey], queryFn: async () => { - if (!vaultAddress || caps.length === 0) { + if (!normalizedVaultAddress || caps.length === 0) { return []; } const client = getClient(chainId); const contracts = caps.map((cap) => ({ - address: vaultAddress, + address: normalizedVaultAddress, abi: vaultv2Abi, functionName: 'allocation' as const, args: [cap.capId as `0x${string}`], @@ -51,7 +53,7 @@ export function useAllocationsQuery({ vaultAddress, chainId, caps = [], enabled cap, })); }, - enabled: enabled && Boolean(vaultAddress) && caps.length > 0, + enabled: enabled && Boolean(normalizedVaultAddress) && caps.length > 0, staleTime: 30_000, // 30 seconds - allocation data is cacheable }); diff --git a/src/hooks/useMorphoMarketAdapters.ts b/src/hooks/useMorphoMarketAdapters.ts index e719bb80..e67bd071 100644 --- a/src/hooks/useMorphoMarketAdapters.ts +++ b/src/hooks/useMorphoMarketAdapters.ts @@ -16,24 +16,28 @@ export function useMorphoMarketAdapters({ vaultAddress, chainId }: { vaultAddres const adapters = useMemo(() => { const adapterDetails = query.data?.adapterDetails ?? []; + const adapterDetailsByAddress = new Map(adapterDetails.map((adapterDetail) => [adapterDetail.address, adapterDetail])); + const adapterAddresses = [...(query.data?.adapters ?? []), ...adapterDetails.map((adapterDetail) => adapterDetail.address)]; + const seenAddresses = new Set(); - if (adapterDetails.length > 0) { - return adapterDetails.map((adapterDetail) => ({ - adapter: adapterDetail.address as Address, - adapterType: adapterDetail.adapterType, - factoryAddress: adapterDetail.factoryAddress as Address, - id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterDetail.address}`, - parentVault: (vaultAddress ?? zeroAddress) as Address, - })); - } - - return (query.data?.adapters ?? []).map((adapterAddress) => ({ - adapter: adapterAddress as Address, - adapterType: undefined, - factoryAddress: undefined, - id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, - parentVault: (vaultAddress ?? zeroAddress) as Address, - })); + return adapterAddresses.flatMap((adapterAddress) => { + if (seenAddresses.has(adapterAddress)) { + return []; + } + + seenAddresses.add(adapterAddress); + const adapterDetail = adapterDetailsByAddress.get(adapterAddress); + + return [ + { + adapter: adapterAddress as Address, + adapterType: adapterDetail?.adapterType, + factoryAddress: adapterDetail?.factoryAddress as Address | undefined, + id: `${chainId}-${vaultAddress ?? 'unknown'}-${adapterAddress}`, + parentVault: (vaultAddress ?? zeroAddress) as Address, + }, + ]; + }); }, [chainId, query.data?.adapterDetails, query.data?.adapters, vaultAddress]); const { primaryAdapter, primaryAdapterType, primaryFactoryAddress } = useMemo(() => { diff --git a/src/hooks/useVaultPage.ts b/src/hooks/useVaultPage.ts index 756741a1..bb3a1d73 100644 --- a/src/hooks/useVaultPage.ts +++ b/src/hooks/useVaultPage.ts @@ -36,17 +36,19 @@ export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVau const { refetch: refetchContract } = contract; const { refetch: refetchAdapter } = adapterQuery; const { refetch: refetchAllocations } = allocationsQuery; + const hasResolvedAdapterState = !adapterQuery.isLoading && !adapterQuery.error; + const hasResolvedVaultState = !vaultDataQuery.isLoading && !vaultDataQuery.isError; // Complex derived state: isVaultInitialized (needs multiple sources) const isVaultInitialized = useMemo(() => { - if (adapterQuery.isLoading || vaultDataQuery.isLoading) return false; + if (!hasResolvedAdapterState || !hasResolvedVaultState) return false; if (!adapterQuery.primaryAdapter) return false; return vaultDataQuery.data !== null && vaultDataQuery.data !== undefined; - }, [adapterQuery.isLoading, adapterQuery.primaryAdapter, vaultDataQuery.isLoading, vaultDataQuery.data]); + }, [adapterQuery.primaryAdapter, hasResolvedAdapterState, hasResolvedVaultState, vaultDataQuery.data]); const needsAdapterDeployment = useMemo( - () => !adapterQuery.isLoading && !adapterQuery.primaryAdapter, - [adapterQuery.isLoading, adapterQuery.primaryAdapter], + () => hasResolvedAdapterState && !adapterQuery.primaryAdapter, + [adapterQuery.primaryAdapter, hasResolvedAdapterState], ); // Fetch adapter positions for APY calculation @@ -94,9 +96,17 @@ export function useVaultPage({ vaultAddress, chainId, connectedAddress }: UseVau const needsInitialization = useMemo(() => { const isLoading = vaultDataQuery.isLoading || contract.isLoading || adapterQuery.isLoading; if (isLoading) return false; + if (!hasResolvedAdapterState || !hasResolvedVaultState) return false; if (isVaultInitialized) return false; return true; - }, [vaultDataQuery.isLoading, contract.isLoading, adapterQuery.isLoading, isVaultInitialized]); + }, [ + vaultDataQuery.isLoading, + contract.isLoading, + adapterQuery.isLoading, + hasResolvedAdapterState, + hasResolvedVaultState, + isVaultInitialized, + ]); // Aggregated refetch function (convenience) const refetchAll = useCallback(() => { diff --git a/src/hooks/useVaultQueryRefresh.ts b/src/hooks/useVaultQueryRefresh.ts index c303ec4c..3ce6629b 100644 --- a/src/hooks/useVaultQueryRefresh.ts +++ b/src/hooks/useVaultQueryRefresh.ts @@ -27,7 +27,7 @@ const refetchVaultQuerySet = async (queryClient: QueryClient, vaultAddress: Addr await Promise.all([ queryClient.refetchQueries({ queryKey: ['vault-v2-data', normalizedVaultAddress, chainId], exact: false }), - queryClient.refetchQueries({ queryKey: ['vault-allocations', vaultAddress, chainId], exact: false }), + queryClient.refetchQueries({ queryKey: ['vault-allocations', normalizedVaultAddress, chainId], exact: false }), queryClient.refetchQueries({ queryKey: ['user-vaults-v2'], exact: false }), ]); }; @@ -51,7 +51,7 @@ export function useVaultQueryRefresh({ vaultAddress, chainId }: { vaultAddress?: const [refreshInProgress, setRefreshInProgress] = useState(false); const normalizedVaultAddress = vaultAddress?.toLowerCase() as Address | undefined; const vaultDataFetchCount = useIsFetching({ queryKey: ['vault-v2-data', normalizedVaultAddress, chainId] }); - const vaultAllocationFetchCount = useIsFetching({ queryKey: ['vault-allocations', vaultAddress, chainId] }); + const vaultAllocationFetchCount = useIsFetching({ queryKey: ['vault-allocations', normalizedVaultAddress, chainId] }); const userVaultFetchCount = useIsFetching({ queryKey: ['user-vaults-v2'] }); const refetch = useCallback( diff --git a/src/hooks/useVaultV2Data.ts b/src/hooks/useVaultV2Data.ts index 038420c6..c6dabd1e 100644 --- a/src/hooks/useVaultV2Data.ts +++ b/src/hooks/useVaultV2Data.ts @@ -56,16 +56,21 @@ const fetchBasicVaultRpcData = async (vaultAddress: Address, chainId: SupportedN allowFailure: true, }); + const hasCoreSuccess = owner.status === 'success' || curator.status === 'success' || asset.status === 'success'; const adaptersLength = await client .readContract({ ...contractBase, functionName: 'adaptersLength', args: [], }) - .catch(() => 0n); + .catch(() => null); + + if (!hasCoreSuccess && adaptersLength === null) { + throw new Error('RPC_UNAVAILABLE'); + } let adapters: string[] = []; - if (adaptersLength > 0n) { + if (adaptersLength && adaptersLength > 0n) { const adapterResults = await client.multicall({ contracts: Array.from({ length: Number(adaptersLength) }, (_, index) => ({ ...contractBase, From d8bf70219d46eb26cde035f6c154b28993e4c71e Mon Sep 17 00:00:00 2001 From: antoncoding Date: Sun, 15 Mar 2026 22:06:29 +0800 Subject: [PATCH 7/7] chore: remove graphql endpoint on vercel --- app/api/monarch/graphql/route.ts | 46 ------------------- app/api/monarch/utils.ts | 9 ---- docs/TECHNICAL_OVERVIEW.md | 36 +++++++++++---- src/data-sources/monarch-api/fetchers.ts | 10 +++- .../modals/vault-initialization-modal.tsx | 14 +++--- 5 files changed, 43 insertions(+), 72 deletions(-) delete mode 100644 app/api/monarch/graphql/route.ts diff --git a/app/api/monarch/graphql/route.ts b/app/api/monarch/graphql/route.ts deleted file mode 100644 index bf92b5df..00000000 --- a/app/api/monarch/graphql/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import { - MONARCH_GRAPHQL_API_KEY, - MONARCH_GRAPHQL_TIMEOUT_MS, - fetchMonarchUpstream, - getMonarchGraphqlUrl, - getMonarchRouteFailure, -} from '../utils'; -import { reportApiRouteError } from '@/utils/sentry-server'; - -export async function POST(request: NextRequest) { - if (!MONARCH_GRAPHQL_API_KEY) { - console.error('[Monarch GraphQL API] Missing MONARCH_GRAPHQL_API_KEY'); - return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); - } - - try { - const body = await request.json(); - const response = await fetchMonarchUpstream(getMonarchGraphqlUrl(), MONARCH_GRAPHQL_TIMEOUT_MS, { - method: 'POST', - headers: { - Authorization: `Bearer ${MONARCH_GRAPHQL_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error('[Monarch GraphQL API] Error:', response.status, errorText); - return NextResponse.json({ error: 'Failed to fetch Monarch GraphQL data' }, { status: response.status }); - } - - return NextResponse.json(await response.json()); - } catch (error) { - const failure = getMonarchRouteFailure(error, 'Failed to fetch Monarch GraphQL data', 'Monarch GraphQL request timed out'); - - reportApiRouteError(error, { - route: '/api/monarch/graphql', - method: 'POST', - status: failure.status, - }); - console.error('[Monarch GraphQL API] Failed to fetch:', error); - return NextResponse.json({ error: failure.message }, { status: failure.status }); - } -} diff --git a/app/api/monarch/utils.ts b/app/api/monarch/utils.ts index 80434a3b..3d29654b 100644 --- a/app/api/monarch/utils.ts +++ b/app/api/monarch/utils.ts @@ -1,9 +1,5 @@ export const MONARCH_METRICS_API_ENDPOINT = process.env.MONARCH_METRICS_API_ENDPOINT; export const MONARCH_METRICS_API_KEY = process.env.MONARCH_METRICS_API_KEY; -export const MONARCH_GRAPHQL_API_ENDPOINT = process.env.NEXT_PUBLIC_MONARCH_API_NEW; -export const MONARCH_GRAPHQL_API_KEY = process.env.MONARCH_GRAPHQL_API_KEY; - -export const MONARCH_GRAPHQL_TIMEOUT_MS = 10_000; export const MONARCH_METRICS_TIMEOUT_MS = 5_000; const isAbortError = (error: unknown): error is Error => error instanceof Error && error.name === 'AbortError'; @@ -40,8 +36,3 @@ export const getMonarchMetricsUrl = (path: string): URL => { if (!MONARCH_METRICS_API_ENDPOINT) throw new Error('MONARCH_METRICS_API_ENDPOINT not configured'); return new URL(path, MONARCH_METRICS_API_ENDPOINT.replace(/\/$/, '')); }; - -export const getMonarchGraphqlUrl = (): URL => { - if (!MONARCH_GRAPHQL_API_ENDPOINT) throw new Error('NEXT_PUBLIC_MONARCH_API_NEW not configured'); - return new URL(MONARCH_GRAPHQL_API_ENDPOINT); -}; diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index e120664c..ffa86c72 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -6,7 +6,7 @@ Monarch is a client-side DeFi dashboard for the Morpho Blue lending protocol. It **Key Architectural Decisions:** - Next.js 15 App Router with React 18 -- Dual data source strategy: Morpho API (primary) → Subgraph (fallback) +- Multi-source strategy: Morpho API + Monarch API + selective subgraph fallback - Zustand for client state, React Query for server state - All user data in localStorage (no backend DB) - Multi-chain support with custom RPC override capability @@ -148,12 +148,18 @@ MorphoChainlinkOracleData { ## Data Sources -### Dual-Source Strategy +### Multi-Source Strategy ``` -Primary: Morpho API (https://blue-api.morpho.org/graphql) - ↓ (if unavailable or unsupported chain) -Fallback: Subgraph (The Graph / Goldsky) +Markets / positions: Morpho API (https://blue-api.morpho.org/graphql) + ↓ (if unavailable or unsupported chain) + Subgraph (The Graph / Goldsky) + +Autovault metadata: Monarch GraphQL (https://api.monarchlend.xyz/graphql) + ↓ (if indexer lag / API failure) + Narrow on-chain RPC fallback + +Market metrics: Monarch metrics API via `/api/monarch/metrics` ``` **Morpho API Supported Chains:** Mainnet, Base, Unichain, Polygon, Arbitrum, HyperEVM, Monad @@ -175,7 +181,9 @@ Fallback: Subgraph (The Graph / Goldsky) | Market state (APY, utilization) | Morpho API | 30s stale | `useMarketData` | | User positions | Morpho API + on-chain | 5 min | `useUserPositions` | | Vaults list | Morpho API | 5 min | `useAllMorphoVaultsQuery` | -| Vault allocations | On-chain (Wagmi) | On demand | `useAllocations` | +| User autovault metadata | Monarch GraphQL + on-chain enrichment | 60s | `useUserVaultsV2Query` | +| Vault detail/settings metadata | Monarch GraphQL + narrow RPC fallback | 30s | `useVaultV2Data` | +| Vault allocations | On-chain multicall | 30s | `useAllocationsQuery` | | Token balances | On-chain multicall | 5 min | `useUserBalancesQuery` | | Oracle prices | Morpho API | 5 min | `useOracleDataQuery` | | Merkl rewards | Merkl API | On demand | `useMerklCampaignsQuery` | @@ -200,9 +208,11 @@ Split: allMarkets vs whitelistedMarkets **Vault Data Flow:** ``` -1. Fetch vault list from API -2. Wagmi contract reads for owner, curator, caps -3. Historical allocations via subgraph +1. Fetch chain-scoped vault metadata from Monarch GraphQL +2. Enrich user-specific balances / totalAssets via multicall where needed +3. Use narrow RPC fallback only when Monarch vault metadata is unavailable +4. Fetch live allocations from on-chain `allocation(capId)` reads +5. After vault writes, use shared bounded retry refreshes so Monarch indexing can catch up ``` --- @@ -256,6 +266,7 @@ All hooks in `/src/hooks/queries/` follow React Query patterns: | `useOracleDataQuery` | `['oracle-data']` | 5 min | 5 min | Yes | | `useUserBalancesQuery` | `['user-balances', addr, networks]` | 30s | - | Yes | | `useUserVaultsV2Query` | `['user-vaults-v2', addr]` | 60s | - | Yes | +| `useVaultV2Data` | `['vault-v2-data', addr, chainId]` | 30s | - | No | | `useMarketLiquidations` | `['marketLiquidations', id, net]` | 5 min | - | Yes | | `useUserTransactionsQuery` | `['user-transactions', ...]` | 60s | - | No | | `useAllocationsQuery` | `['vault-allocations', ...]` | 30s | - | No | @@ -281,6 +292,11 @@ Fallback Strategy: - `cache: 'no-store'` (disable browser cache) - Throws on GraphQL errors (strict) +**Monarch GraphQL** (`/src/data-sources/monarch-api/fetchers.ts`): +- Endpoint: `NEXT_PUBLIC_MONARCH_API_NEW` +- Browser fetch with `NEXT_PUBLIC_MONARCH_API_KEY` +- Used as the primary read path for autovault V2 metadata + **Subgraph** (`/src/data-sources/subgraph/fetchers.ts`): - Configurable URL per network - Logs GraphQL errors but continues (lenient) @@ -322,6 +338,8 @@ Fallback Strategy: | Service | Endpoint | Purpose | |---------|----------|---------| | Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions | +| Monarch GraphQL | `https://api.monarchlend.xyz/graphql` | Autovault metadata, adapters, caps | +| Monarch Metrics | `/api/monarch/metrics` → external Monarch metrics API | Market metrics and admin stats | | The Graph | Per-chain subgraph URLs | Fallback data, suppliers, borrowers | | Merkl API | `https://api.merkl.xyz` | Reward campaigns | | Velora API | `https://api.paraswap.io` | Swap quotes and executable tx payloads | diff --git a/src/data-sources/monarch-api/fetchers.ts b/src/data-sources/monarch-api/fetchers.ts index 0bb15df3..b016182a 100644 --- a/src/data-sources/monarch-api/fetchers.ts +++ b/src/data-sources/monarch-api/fetchers.ts @@ -8,14 +8,22 @@ type MonarchGraphqlFetcherOptions = { signal?: AbortSignal; }; +const MONARCH_GRAPHQL_API_ENDPOINT = process.env.NEXT_PUBLIC_MONARCH_API_NEW; +const MONARCH_GRAPHQL_API_KEY = process.env.NEXT_PUBLIC_MONARCH_API_KEY; + export const monarchGraphqlFetcher = async >( query: string, variables: GraphQLVariables = {}, options: MonarchGraphqlFetcherOptions = {}, ): Promise => { - const response = await fetch('/api/monarch/graphql', { + if (!MONARCH_GRAPHQL_API_ENDPOINT || !MONARCH_GRAPHQL_API_KEY) { + throw new Error('Monarch GraphQL client not configured'); + } + + const response = await fetch(MONARCH_GRAPHQL_API_ENDPOINT, { method: 'POST', headers: { + Authorization: `Bearer ${MONARCH_GRAPHQL_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ diff --git a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx index 64c37955..74e9e059 100644 --- a/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx +++ b/src/features/autovault/components/vault-detail/modals/vault-initialization-modal.tsx @@ -12,6 +12,7 @@ import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/ import { Spinner } from '@/components/ui/spinner'; import { useDeployMorphoMarketAdapter } from '@/hooks/useDeployMorphoMarketAdapter'; import { useMorphoMarketAdapters } from '@/hooks/useMorphoMarketAdapters'; +import { useVaultQueryRefresh } from '@/hooks/useVaultQueryRefresh'; import { useVaultV2Data } from '@/hooks/useVaultV2Data'; import { useVaultV2 } from '@/hooks/useVaultV2'; import { v2AgentsBase } from '@/utils/monarch-agent'; @@ -225,6 +226,10 @@ export function VaultInitializationModal() { }); const { completeInitialization, isInitializing } = vaultContract; + const { refetch: refetchVaultQueries } = useVaultQueryRefresh({ + vaultAddress: vaultAddressValue, + chainId, + }); // Fetch adapter const { primaryAdapter: marketAdapter, refetch: refetchAdapter } = useMorphoMarketAdapters({ @@ -301,10 +306,7 @@ export function VaultInitializationModal() { return; } - // Trigger refetch after initialization completes. - void vaultDataQuery.refetch(); - void vaultContract.refetch(); - void refetchAdapter(); + await refetchVaultQueries({ includeRetries: true }); close(); } catch (_error) { @@ -312,10 +314,8 @@ export function VaultInitializationModal() { } }, [ completeInitialization, - vaultDataQuery, - vaultContract, - refetchAdapter, close, + refetchVaultQueries, registryAddress, selectedAgent, adapterAddress,