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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ When touching transaction and position flows, validation MUST include all releva
49. **Merged-history pagination query integrity**: when a UI paginates over a merged transaction/history dataset that is fetched as a full filtered set (for example because the backend cannot page a merged multi-table stream correctly), keep page changes local to the UI and do not include `skip`/`first` or current-page state in the upstream query key. Fetch once per filter scope, then slice locally, so pagination controls do not re-trigger the full upstream history cycle on every click.
50. **Market-registry fallback integrity**: shared multi-chain market registry reads must preserve per-chain isolation across providers. If a chain-scoped Morpho market-list request fails, fall back to a chain-scoped Monarch/Envio market-list query before subgraph, keep the fallback fully paginated, and apply the same canonical market guards (for example non-zero collateral token and non-zero IRM) so one broken chain such as HyperEVM does not blank the registry for healthy chains.
51. **Fallback token-decimal integrity**: market-registry fallback paths must not invent ERC20 decimals for unknown tokens. Resolve unknown token decimals through shared chain-scoped RPC multicalls keyed by canonical `chainId + address`, and fail closed for any market whose required token metadata cannot be resolved safely.
52. **Market-detail live-state source integrity**: `useMarketData` must treat Monarch/Envio as the primary source for live market state fields that drive the market page (`supplyApy`, `borrowApy`, `apyAtTarget`, `utilization`, balances/shares/liquidity where available), while preserving fallback-shell metadata fields that Monarch does not yet expose with parity (for example `whitelisted`, `supplyingVaults`, and rolling daily/weekly/monthly APYs). Do not let broken Morpho live state leak directly into market-detail headers/charts when Monarch state is available, and do not replace the whole market shell with partial Monarch metadata.


### REQUIRED: Regression Rule Capture
Expand Down
46 changes: 29 additions & 17 deletions docs/TECHNICAL_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,20 @@ NonStandardOracleOutput {
### Multi-Source Strategy

```
Markets / positions: Morpho API (https://blue-api.morpho.org/graphql)
Market registry / market shells:
Morpho API (https://blue-api.morpho.org/graphql)
↓ (if unavailable or unsupported chain)
Monarch GraphQL (https://api.monarchlend.xyz/graphql)
Subgraph (The Graph / Goldsky)

Market detail live state + historical charts:
Monarch GraphQL (https://api.monarchlend.xyz/graphql)
↓ (for shell metadata fallback and optional USD backfill)
Morpho API / Subgraph
↓ (fresh balances / shares / liquidity override)
On-chain RPC snapshot

Autovault metadata: Monarch GraphQL (https://api.monarchlend.xyz/graphql)
↓ (if indexer lag / API failure)
Narrow on-chain RPC fallback
Expand All @@ -195,7 +205,8 @@ Market metrics: Monarch metrics API via `/api/monarch/metrics`
|-----------|--------|---------|------------|
| Markets list | Morpho API → Monarch API → Subgraph | 5 min stale | `useMarketsQuery` |
| Market metrics (flows, trending) | Monarch API | 5 min stale | `useMarketMetricsQuery` |
| Market state (APY, utilization) | RPC snapshot + Morpho API/Subgraph | 30s stale | `useMarketData` |
| Market state (APY, utilization, balances) | Monarch market state + Morpho/Subgraph shell + RPC snapshot | 30s stale | `useMarketData` |
| Market historical chart series | Monarch GraphQL → Morpho API → Subgraph | 5 min stale | `useMarketHistoricalData` |
| User positions | Monarch position discovery + on-chain snapshots + market registry from `useProcessedMarkets` | 5 min | `useUserPositions` |
| User transaction history | Monarch GraphQL → Morpho API → Subgraph (`assetIds` queries still skip Monarch) | 60s | `useUserTransactionsQuery` |
| Vaults list | Morpho API | 5 min | `useAllMorphoVaultsQuery` |
Expand All @@ -220,11 +231,11 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he

| Hook / Family | Responsibility | Infra Today | Full Monarch Support Still Needs |
|---------------|----------------|-------------|----------------------------------|
| `useMarketsQuery` | Global market registry used across the app | Morpho API first per chain, then Monarch API, then subgraph | Monarch single-market detail parity |
| `useMarketsQuery` | Global market registry used across the app | Morpho API first per chain, then Monarch API, then subgraph | Rolling daily/weekly/monthly APYs plus whitelist/supplying-vault metadata parity if we ever want Monarch-first registry reads |
| `useProcessedMarkets` | Blacklist/filtering layer on top of market registry, plus USD backfill | `useMarketsQuery` + `useTokenPrices` | Inherits `useMarketsQuery`; also needs a Monarch-native token price source if we want to remove Morpho price reads |
| `useMarketData` | Single-market detail shell with freshest live state | RPC snapshot + Morpho API, then subgraph | Monarch single-market metadata/detail path |
| `useMarketHistoricalData` | Historical market chart series | Morpho historical API, then subgraph | Monarch historical market snapshots/timeseries |
| `useTokenPrices` | Token USD price lookup and peg fallback used by markets/admin stats | Morpho price API + major price fallback | Monarch price endpoint or another canonical replacement |
| `useMarketData` | Single-market detail shell with freshest live state | Monarch live-state overlay on Morpho/Subgraph shell, then RPC snapshot override | Whitelist, supplying-vault, and rolling-APY metadata parity if we want to remove the shell fallback entirely |
| `useMarketHistoricalData` | Historical market chart series | Monarch historical snapshots first; Morpho API/Subgraph only for fallback | Already aligned for the current asset-only market charts |
| `useTokenPrices` | Token USD price lookup and peg fallback used by markets/admin stats | Morpho price API + major price fallback | Intentionally Morpho/major-price backed today |
| `useUserPositions` | Discover all markets where a user has positions, then attach live balances | Monarch batched `Position` discovery + RPC snapshots/oracle reads + market metadata from `useProcessedMarkets`; Morpho/Subgraph fallback for discovery | Monarch market registry/detail if position objects should no longer depend on Morpho/Subgraph market metadata |
| `useUserPosition` | Single-market user position | RPC snapshot first; if snapshot unavailable, Monarch position state when local market exists; then Morpho/Subgraph fallback | Same market-registry/detail gap as `useUserPositions` |
| `useUserTransactionsQuery` / `fetchUserTransactions` | User history across one or many chains | Monarch user-event tables first; fallback Morpho API, then subgraph; `assetIds` filter still bypasses Monarch | Asset-address filtered history support to fully back reports and any asset-scoped history views |
Expand All @@ -248,8 +259,8 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he
|---------------|----------------|-------------|----------------------------------|
| `useUserVaultsV2Query` | User vault list with optional balance, TVL, and yield enrichment | Monarch vault metadata + RPC balances/totalAssets + RPC 4626 yield snapshots | Already off Morpho for yield; no new Envio schema gap identified |
| `useVaultV2Data` | Vault detail/settings metadata for a single vault | Monarch vault detail first, narrow RPC fallback if metadata unavailable | Already aligned with Monarch-first design |
| `useAllMorphoVaultsQuery` | Global whitelisted vault registry | Morpho API only | Monarch/public vault registry parity |
| `usePublicAllocatorVaults` | Public allocator config for supplying vaults in a market | Morpho API only | Monarch/public allocator config endpoint parity |
| `useAllMorphoVaultsQuery` | Global whitelisted vault registry | Morpho API only | Intentionally Morpho-only today |
| `usePublicAllocatorVaults` | Public allocator config for supplying vaults in a market | Morpho API only | Intentionally Morpho-only today |
| `useAllocationsQuery` | Live vault `allocation(capId)` values | Pure RPC multicall | No Envio gap |
| `usePublicAllocatorLiveData` | Live flow caps, vault supply, and liquidity for allocator UX | Pure RPC multicall | No Envio gap |
| `useVaultHistoricalApy` / `use4626VaultAPR` | Historical 4626 yield and expected carry calculations | Pure RPC share-price snapshots + RPC Morpho market reads | No Envio gap |
Expand Down Expand Up @@ -370,8 +381,8 @@ Fallback Strategy:

**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, market-detail reads, and admin transaction reads
- Browser fetch against a public endpoint; `NEXT_PUBLIC_MONARCH_API_KEY` is optional and only sent when configured
- Used as the primary read path for autovault V2 metadata, market-detail live state/history/activity, and admin transaction reads

**Subgraph** (`/src/data-sources/subgraph/fetchers.ts`):
- Configurable URL per network
Expand All @@ -388,10 +399,11 @@ Fallback Strategy:
- useOracleMetadata() for oracle classification and feed details
3. Market fetch:
a. Try on-chain snapshot (viem multicall)
b. Try Morpho API (if supported)
c. Fallback to Subgraph
d. Merge snapshot with API state
a. Start Monarch single-market state fetch
b. Start Morpho API / Subgraph shell fallback
c. Start on-chain snapshot (viem multicall)
d. Merge Monarch live state into the shell when both exist
e. Override balances / shares / liquidity with the RPC snapshot
4. Oracle metadata resolves separately by `chainId + oracleAddress`
- Standard/meta oracle UI reads scanner-native `OracleOutputData` / `MetaOracleOutputData`
Expand All @@ -402,10 +414,10 @@ Fallback Strategy:

### Key Patterns

1. **Fallback Chain**: API → Subgraph → Empty
1. **Feature-Scoped Priority**: Monarch-first for market detail/history/activity, Morpho-first for the global market registry, Subgraph last
2. **Parallel Execution**: `Promise.all()` for multi-network
3. **Graceful Degradation**: Partial data > Error
4. **Two-Phase Market**: On-chain snapshot + API state
4. **Three-Phase Market Detail**: Monarch live state + fallback shell + RPC snapshot
5. **Hybrid Reads**: Scanner metadata for oracle structure + live RPC/API for market state

---
Expand All @@ -416,7 +428,7 @@ 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, market detail/activity, admin transactions |
| Monarch GraphQL | `https://api.monarchlend.xyz/graphql` | Autovault metadata, market live state, historical charts, market detail/activity, admin transactions |
| 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 |
Expand Down
15 changes: 10 additions & 5 deletions src/data-sources/monarch-api/fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,21 @@ export const monarchGraphqlFetcher = async <T extends Record<string, unknown>>(
variables: GraphQLVariables = {},
options: MonarchGraphqlFetcherOptions = {},
): Promise<T> => {
if (!MONARCH_GRAPHQL_API_ENDPOINT || !MONARCH_GRAPHQL_API_KEY) {
if (!MONARCH_GRAPHQL_API_ENDPOINT) {
throw new Error('Monarch GraphQL client not configured');
}

const headers: Record<string, string> = {
'Content-Type': 'application/json',
};

if (MONARCH_GRAPHQL_API_KEY) {
headers.Authorization = `Bearer ${MONARCH_GRAPHQL_API_KEY}`;
}
Comment thread
antoncoding marked this conversation as resolved.

const response = await fetch(MONARCH_GRAPHQL_API_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${MONARCH_GRAPHQL_API_KEY}`,
'Content-Type': 'application/json',
},
headers,
body: JSON.stringify({
query,
variables,
Expand Down
174 changes: 174 additions & 0 deletions src/data-sources/monarch-api/historical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { formatUnits } from 'viem';
import { envioMarketDailySnapshotsQuery, envioMarketHourlySnapshotsQuery } from '@/graphql/envio-queries';
import { convertAprToApy } from '@/utils/rateMath';
import type { MarketRates, MarketVolumes, TimeseriesOptions } from '@/utils/types';
import type { SupportedNetworks } from '@/utils/networks';
import type { HistoricalDataSuccessResult } from '../morpho-api/historical';
import { monarchGraphqlFetcher } from './fetchers';

type MonarchHistoricalSnapshotRow = {
timestamp: string;
totalSupplyAssets: string;
totalBorrowAssets: string;
supplyRateApr: string;
borrowRateApr: string;
rateAtTargetApr: string;
utilization: string;
};

type MonarchHistoricalSnapshotsResponse = {
data?: {
MarketHourlySnapshot?: MonarchHistoricalSnapshotRow[];
MarketDailySnapshot?: MonarchHistoricalSnapshotRow[];
};
};

const WAD_DECIMALS = 18;
const MONARCH_HISTORICAL_PAGE_LIMIT = 1_000;
const MONARCH_HISTORICAL_TIMEOUT_MS = 10_000;

const sortByTimestamp = (left: { x: number }, right: { x: number }): number => left.x - right.x;

const parseIntegerValue = (value: string): bigint | null => {
try {
return BigInt(value);
} catch {
return null;
}
};

const parseWadDecimal = (value: string): number | null => {
try {
return Number(formatUnits(BigInt(value), WAD_DECIMALS));
} catch {
return null;
}
};

const createEmptyVolumes = (): MarketVolumes => ({
supplyAssetsUsd: [],
borrowAssetsUsd: [],
liquidityAssetsUsd: [],
supplyAssets: [],
borrowAssets: [],
liquidityAssets: [],
});

const transformSnapshotsToHistoricalResult = (snapshots: MonarchHistoricalSnapshotRow[]): HistoricalDataSuccessResult | null => {
if (snapshots.length === 0) {
return null;
}

const rates: MarketRates = {
supplyApy: [],
borrowApy: [],
apyAtTarget: [],
utilization: [],
};
const volumes = createEmptyVolumes();

for (const snapshot of snapshots) {
const timestamp = Number.parseInt(snapshot.timestamp, 10);
const supplyAssets = parseIntegerValue(snapshot.totalSupplyAssets);
const borrowAssets = parseIntegerValue(snapshot.totalBorrowAssets);
const supplyApr = parseWadDecimal(snapshot.supplyRateApr);
const borrowApr = parseWadDecimal(snapshot.borrowRateApr);
const targetApr = parseWadDecimal(snapshot.rateAtTargetApr);
const utilization = parseWadDecimal(snapshot.utilization);

if (
!Number.isFinite(timestamp) ||
supplyAssets === null ||
borrowAssets === null ||
supplyApr === null ||
borrowApr === null ||
targetApr === null ||
utilization === null
) {
continue;
}

rates.supplyApy.push({
x: timestamp,
y: convertAprToApy(supplyApr),
});
rates.borrowApy.push({
x: timestamp,
y: convertAprToApy(borrowApr),
});
rates.apyAtTarget.push({
x: timestamp,
y: convertAprToApy(targetApr),
});
rates.utilization.push({
x: timestamp,
y: utilization,
});

volumes.supplyAssets.push({
x: timestamp,
y: supplyAssets,
});
volumes.borrowAssets.push({
x: timestamp,
y: borrowAssets,
});
volumes.liquidityAssets.push({
x: timestamp,
y: supplyAssets - borrowAssets,
});
}

if (rates.supplyApy.length === 0) {
return null;
}

Object.values(rates).forEach((series) => series.sort(sortByTimestamp));
Object.values(volumes).forEach((series) => series.sort(sortByTimestamp));

return { rates, volumes };
};

export const fetchMonarchMarketHistoricalData = async (
marketId: string,
chainId: SupportedNetworks,
options: TimeseriesOptions,
): Promise<HistoricalDataSuccessResult | null> => {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => {
controller.abort();
}, MONARCH_HISTORICAL_TIMEOUT_MS);

const variables = {
chainId,
marketId: marketId.toLowerCase(),
startTimestamp: String(options.startTimestamp),
endTimestamp: String(options.endTimestamp),
limit: MONARCH_HISTORICAL_PAGE_LIMIT,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const query =
options.interval === 'HOUR'
? envioMarketHourlySnapshotsQuery
: envioMarketDailySnapshotsQuery;

try {
const response = await monarchGraphqlFetcher<MonarchHistoricalSnapshotsResponse>(query, variables, {
signal: controller.signal,
});
const snapshots =
options.interval === 'HOUR'
? (response.data?.MarketHourlySnapshot ?? [])
: (response.data?.MarketDailySnapshot ?? []);

return transformSnapshotsToHistoricalResult(snapshots);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Monarch historical request timed out after ${MONARCH_HISTORICAL_TIMEOUT_MS}ms`);
}

throw error;
} finally {
globalThis.clearTimeout(timeoutId);
}
};
2 changes: 1 addition & 1 deletion src/data-sources/monarch-api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { monarchGraphqlFetcher } from './fetchers';
export { fetchMonarchMarkets } from './markets';
export { fetchMonarchMarket, fetchMonarchMarkets } from './markets';
export {
fetchMonarchUserPositionMarketsForNetworks,
fetchMonarchUserPositionStateForMarket,
Expand Down
25 changes: 22 additions & 3 deletions src/data-sources/monarch-api/markets.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Market as BlueMarket, MarketParams as BlueMarketParams } from '@morpho-org/blue-sdk';
import { formatUnits, type Address, zeroAddress } from 'viem';
import { buildEnvioMarketsPageQuery } from '@/graphql/envio-queries';
import { buildEnvioMarketsPageQuery, envioMarketByIdQuery } from '@/graphql/envio-queries';
import { getMorphoAddress } from '@/utils/morpho';
import { isSupportedChain, type SupportedNetworks } from '@/utils/networks';
import { blacklistTokens, infoToKey } from '@/utils/tokens';
Expand Down Expand Up @@ -195,6 +195,16 @@ const fetchMonarchMarketsPage = async (query: string, variables: Record<string,
}
};

const mapMonarchMarketRows = async (rows: MonarchMarketRow[]): Promise<Market[]> => {
if (rows.length === 0) {
return [];
}

const tokenInfos = await resolveTokenInfos(getMarketTokenInputs(rows));

return rows.map((market) => mapMonarchMarketToMarket(market, tokenInfos)).filter((market): market is Market => market !== null);
};

// If `network` is omitted, this fetches the merged multi-chain market registry in one query path.
export const fetchMonarchMarkets = async (network?: SupportedNetworks): Promise<Market[]> => {
const query = buildEnvioMarketsPageQuery({
Expand Down Expand Up @@ -224,7 +234,16 @@ export const fetchMonarchMarkets = async (network?: SupportedNetworks): Promise<
offset += rows.length;
}

const tokenInfos = await resolveTokenInfos(getMarketTokenInputs(allRows));
return mapMonarchMarketRows(allRows);
};

export const fetchMonarchMarket = async (uniqueKey: string, network: SupportedNetworks): Promise<Market | null> => {
const rows = await fetchMonarchMarketsPage(envioMarketByIdQuery, {
chainId: network,
marketId: normalizeAddress(uniqueKey),
zeroAddress: MONARCH_MARKETS_ZERO_ADDRESS,
});

return allRows.map((market) => mapMonarchMarketToMarket(market, tokenInfos)).filter((market): market is Market => market !== null);
const [market] = await mapMonarchMarketRows(rows);
return market ?? null;
};
Loading