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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/VALIDATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start
- Market-table data enrichments that affect visible columns or sorting must report degraded readiness to the shared market-data notice surface instead of silently replacing values with empty placeholders.
- Large optional metadata or enrichment queries used only for secondary badges, warnings, filters, or tooltips must be gated or deferred so core table rendering does not wait on them during cold start.
- Vault-scoped pages with configured cap or market IDs must use targeted market reads for first render; do not wait on the global market registry when the vault metadata already identifies the relevant markets.
- Vault adapter selection must be cap-aware when a vault has multiple active adapters; do not let list order alone choose the adapter used for positions, activity, withdrawals, or settings.
- Expensive queries must not start with placeholder dependency data that immediately invalidates the same query. Gate on prerequisite readiness, or use a stable query key that does not refetch equivalent work.
- Expensive enrichment queries derived from filtered, sorted, or paginated rows must wait for the inputs that can change those rows, such as USD price enrichment, before they start.
- Periodic refreshes for RPC or API data must use React Query polling with background refetch disabled, or explicitly pause when `document.visibilityState` is hidden. Do not use raw `setInterval` for mounted data refresh unless hidden-tab behavior is handled.
Expand All @@ -96,6 +97,8 @@ Use this file at the end of non-trivial work. Do not front-load it at task start

- Avoid repeated large UI blocks; extract or reuse only when it reduces real duplication.
- Validate loading, empty, disabled, error, and success states for changed flows; period-derived metrics must not show stale values while recalculating.
- Related analytics sections that share period-dependent data should expose one period control at the shared container level, not competing child-level dropdowns.
- Yield, rate, and share-price charts must use period-aware minimum y-axis bands when comparing growth; do not let data-only auto-fit scaling make materially different APRs look visually identical.
- Cold-start loading for optional metadata or enrichment must not trigger warning/error banners; warn only after a source is partial, stale, or unavailable.
- Product routes must not add render-blocking external font CSS or multi-megabyte custom font assets to the app shell; prefer system font stacks or prove a small subset budget.
- Font performance changes must preserve the intended design-token font families. Optimize with self-hosted subsets or scoped loading, not by silently mapping custom font utilities to system stacks.
Expand All @@ -108,6 +111,7 @@ Use this file at the end of non-trivial work. Do not front-load it at task start
- Relationship metadata must not link to the current page's own account again; only link to counterpart accounts, external protocol pages, or expandable details.
- V2 vault position pages must not render native vault-address market or vault tables when the meaningful market exposure is held by a linked adapter.
- Vault and adapter relationship UI should prefer short chips, address badges, and structural grouping over explanatory paragraphs.
- Vault adapter labels must come from confirmed adapter type metadata or fall back to a generic "vault adapter" label; do not infer protocol-specific adapter types from a missing `adapterType`.
- Vault identity and vault action links should resolve to Monarch's canonical `/vault/:chainId/:address` route by default; external Morpho vault links belong only in explicit "View on Morpho" actions.
- Use available entity icons in compact metadata chips before adding extra explanatory text.
- Dense product headers should use compact chips, short address links, icon buttons, and tooltips for secondary navigation; avoid long text buttons unless they are the primary action.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
'use client';

import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { EarningsPeriod } from '@/stores/usePositionsFilters';

type PositionPeriodSelectorProps = {
period: EarningsPeriod;
onPeriodChange: (period: EarningsPeriod) => void;
className?: string;
contentClassName?: string;
export type PeriodSelectorOption = {
value: EarningsPeriod;
label: string;
};

const PERIOD_OPTIONS: { value: EarningsPeriod; label: string }[] = [
export const EARNINGS_PERIOD_OPTIONS: readonly PeriodSelectorOption[] = [
{ value: 'day', label: '24h' },
{ value: 'week', label: '7 days' },
{ value: 'month', label: '30 days' },
{ value: 'threemonth', label: '3 months' },
{ value: 'sixmonth', label: '6 months' },
{ value: 'all', label: 'All time' },
];

export function PositionPeriodSelector({ period, onPeriodChange, className, contentClassName }: PositionPeriodSelectorProps) {
type PeriodSelectorProps = {
period: EarningsPeriod;
onPeriodChange: (period: EarningsPeriod) => void;
options?: readonly PeriodSelectorOption[];
className?: string;
contentClassName?: string;
};

export function PeriodSelector({
period,
onPeriodChange,
options = EARNINGS_PERIOD_OPTIONS,
className,
contentClassName,
}: PeriodSelectorProps) {
return (
<Select
value={period}
Expand All @@ -28,7 +41,7 @@ export function PositionPeriodSelector({ period, onPeriodChange, className, cont
<SelectValue />
</SelectTrigger>
<SelectContent className={contentClassName}>
{PERIOD_OPTIONS.map((option) => (
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
Expand Down
2 changes: 1 addition & 1 deletion src/contexts/VaultRegistryContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ type AddressLabel = {

type AdapterAddressAlias = {
adapterAddress: string;
adapterType: string;
adapterType?: string;
chainId: number;
vaultAddress: string;
vaultName: string;
Expand Down
92 changes: 82 additions & 10 deletions src/data-sources/monarch-api/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type VaultAdapterDetails = {
};

export type VaultAdapterAlias = {
adapterType: string;
adapterType?: string;
address: string;
chainId: SupportedNetworks;
vaultAddress: string;
Expand Down Expand Up @@ -135,6 +135,20 @@ type MonarchAdapterAliasesResponse = {
};
};

type MonarchVaultAdapterAliasRecord = {
vaultAddress: string;
chainId: number;
name: string | null;
symbol: string | null;
adapters: MonarchVaultAdapter[];
};

type MonarchVaultAdapterAliasesResponse = {
data?: {
Vault?: MonarchVaultAdapterAliasRecord[];
};
};

const MONARCH_ADAPTER_ALIAS_PAGE_SIZE = 1000;
const MONARCH_ADAPTER_ALIAS_MAX_PAGES = 20;

Expand Down Expand Up @@ -278,6 +292,26 @@ const adapterAliasesQuery = `
}
`;

const activeVaultAdapterAliasesQuery = `
query MonarchActiveVaultAdapterAliases($limit: Int!, $offset: Int!) {
Vault(
where: { adapters: { isActive: { _eq: true } } }
order_by: [{ chainId: asc }, { vaultAddress: asc }]
limit: $limit
offset: $offset
) {
vaultAddress
chainId
name
symbol
adapters {
adapterAddress
isActive
}
}
}
`;

export const fetchUserVaultV2DetailsAllNetworks = async (owner: string): Promise<UserVaultV2[]> => {
const response = await monarchGraphqlFetcher<MonarchVaultsResponse>(userVaultsQuery, {
owner: owner.toLowerCase(),
Expand Down Expand Up @@ -359,10 +393,36 @@ const transformAdapterAliasRecord = (adapter: MonarchAdapterAliasRecord): VaultA
};
};

const transformVaultAdapterAliasRecord = (vault: MonarchVaultAdapterAliasRecord): VaultAdapterAlias[] => {
const chainId = toSupportedNetwork(vault.chainId);
const vaultAddress = normalizeAddress(vault.vaultAddress);
const vaultName = vault.name?.trim() || vault.symbol?.trim() || '';
if (!chainId || !vaultAddress || !vaultName) {
return [];
}

return vault.adapters
.filter((adapter) => adapter.isActive)
.map((adapter) => normalizeAddress(adapter.adapterAddress))
.filter(Boolean)
.map((address) => ({
address,
chainId,
vaultAddress,
vaultName,
}));
};

export const fetchMonarchVaultAdapterAliases = async (): Promise<VaultAdapterAlias[]> => {
try {
const aliases: VaultAdapterAlias[] = [];
const seenKeys = new Set<string>();
const aliasesByKey = new Map<string, VaultAdapterAlias>();
const addAlias = (alias: VaultAdapterAlias) => {
const key = `${alias.chainId}:${alias.address}`;
const existing = aliasesByKey.get(key);
if (!existing || (!existing.adapterType && alias.adapterType)) {
aliasesByKey.set(key, alias);
}
};

for (let page = 0; page < MONARCH_ADAPTER_ALIAS_MAX_PAGES; page++) {
const response = await monarchGraphqlFetcher<MonarchAdapterAliasesResponse>(adapterAliasesQuery, {
Expand All @@ -378,21 +438,33 @@ export const fetchMonarchVaultAdapterAliases = async (): Promise<VaultAdapterAli
continue;
}

const key = `${alias.chainId}:${alias.address}`;
if (seenKeys.has(key)) {
continue;
}
addAlias(alias);
}

seenKeys.add(key);
aliases.push(alias);
if (records.length < MONARCH_ADAPTER_ALIAS_PAGE_SIZE) {
break;
}
}

for (let page = 0; page < MONARCH_ADAPTER_ALIAS_MAX_PAGES; page++) {
const response = await monarchGraphqlFetcher<MonarchVaultAdapterAliasesResponse>(activeVaultAdapterAliasesQuery, {
limit: MONARCH_ADAPTER_ALIAS_PAGE_SIZE,
offset: page * MONARCH_ADAPTER_ALIAS_PAGE_SIZE,
});

const records = response.data?.Vault ?? [];
for (const record of records) {
for (const alias of transformVaultAdapterAliasRecord(record)) {
addAlias(alias);
}
}

if (records.length < MONARCH_ADAPTER_ALIAS_PAGE_SIZE) {
break;
}
}

return aliases;
return Array.from(aliasesByKey.values());
} catch (error) {
console.warn('Error fetching Monarch vault adapter aliases:', error);
return [];
Expand Down
86 changes: 86 additions & 0 deletions src/data-sources/morpho-api/vault-share-price-history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { vaultV2SharePriceHistoryQuery } from '@/graphql/vault-queries';
import type { SupportedNetworks } from '@/utils/networks';
import type { TimeseriesOptions } from '@/utils/types';
import { morphoGraphqlFetcher } from './fetchers';

export type MorphoVaultSharePricePoint = {
timestamp: number;
sharePrice: number;
};

type RawSharePricePoint = {
x: unknown;
y: unknown;
};

type VaultV2SharePriceHistoryResponse = {
data?: {
vaultV2ByAddress?: {
historicalState?: {
sharePrice?: RawSharePricePoint[] | null;
} | null;
} | null;
};
errors?: { message: string }[];
};

const sortByTimestamp = (left: MorphoVaultSharePricePoint, right: MorphoVaultSharePricePoint): number =>
left.timestamp - right.timestamp;

function normalizeSharePrice(value: unknown): number | null {
const numericValue = typeof value === 'string' ? Number(value) : value;

if (typeof numericValue !== 'number' || !Number.isFinite(numericValue)) {
return null;
}

return numericValue;
}

export async function fetchMorphoVaultV2SharePriceHistory({
vaultAddress,
chainId,
options,
}: {
vaultAddress: string;
chainId: SupportedNetworks;
options: TimeseriesOptions;
}): Promise<MorphoVaultSharePricePoint[] | null> {
try {
const response = await morphoGraphqlFetcher<VaultV2SharePriceHistoryResponse>(
vaultV2SharePriceHistoryQuery,
{
address: vaultAddress,
chainId,
options,
},
{ timeoutMs: 8000 },
);

const points = response?.data?.vaultV2ByAddress?.historicalState?.sharePrice;
if (!points || points.length === 0) {
return null;
}

const normalizedPoints = points
.map((point) => {
const timestamp = Number(point.x);
const sharePrice = normalizeSharePrice(point.y);
if (!Number.isFinite(timestamp) || sharePrice === null) {
return null;
}

return {
timestamp,
sharePrice,
};
})
.filter((point): point is MorphoVaultSharePricePoint => point !== null)
.sort(sortByTimestamp);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return normalizedPoints.length > 0 ? normalizedPoints : null;
} catch (error) {
console.warn('[vaultSharePriceHistory] Morpho API share price history unavailable:', error);
return null;
}
}
27 changes: 26 additions & 1 deletion src/features/autovault/components/vault-detail/vault-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import { CollateralIconsDisplay } from '@/features/positions/components/collater
import { getSlicedAddress } from '@/utils/address';
import { formatVaultAdapterType } from '@/utils/vaults';

type VaultHeaderAdapterRow = {
adapter: Address;
adapterType?: string;
};

type VaultHeaderProps = {
vaultAddress: Address;
chainId: SupportedNetworks;
Expand All @@ -39,7 +44,8 @@ type VaultHeaderProps = {
collaterals?: { address: string; symbol: string; amount: number }[];
curator?: string;
adapter?: string;
adapters?: { adapter: Address; adapterType?: string }[];
adapters?: VaultHeaderAdapterRow[];
capsAdapters?: VaultHeaderAdapterRow[];
onDeposit: () => void;
onWithdraw: () => void;
onRefresh: () => void;
Expand Down Expand Up @@ -67,6 +73,7 @@ export function VaultHeader({
curator,
adapter,
adapters = [],
capsAdapters = [],
onDeposit,
onWithdraw,
onRefresh,
Expand All @@ -92,6 +99,13 @@ export function VaultHeader({
// Filter for known agents
const knownAllocators = allocators.filter((addr) => findAgent(addr) !== undefined);
const adapterRows = adapters.length > 0 ? adapters : adapter ? [{ adapter: adapter as Address }] : [];
const capsAdapterRows = capsAdapters;
const capsAdapterKeys = new Set(capsAdapterRows.map((row) => row.adapter.toLowerCase()));
const showCapsAdapter = adapterRows.length > 1 && capsAdapterRows.length > 0;
const capsAdapterLabel =
capsAdapterRows.length === 1
? `${formatVaultAdapterType(capsAdapterRows[0].adapterType)} ${getSlicedAddress(capsAdapterRows[0].adapter)}`
: `${capsAdapterRows.length} configured adapters`;

return (
<div className="mt-6 mb-6 space-y-4">
Expand Down Expand Up @@ -147,6 +161,12 @@ export function VaultHeader({
<span>Curator: {getSlicedAddress(curator as Address)}</span>
</>
)}
{showCapsAdapter && (
<>
<span className="text-border">·</span>
<span>Caps: {capsAdapterLabel}</span>
</>
)}
{knownAllocators.length > 0 && (
<>
<span className="text-border">·</span>
Expand Down Expand Up @@ -351,6 +371,11 @@ export function VaultHeader({
address={row.adapter}
chainId={chainId}
/>
{capsAdapterKeys.has(row.adapter.toLowerCase()) && (
<span className="rounded-sm bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-800 dark:bg-green-400/10 dark:text-green-300">
Active
</span>
)}
</div>
))}
</div>
Expand Down
Loading