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
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,13 @@ When touching transaction and position flows, validation MUST include all releva
47. **Indexed event-history pagination integrity**: when stitching user history from raw indexer event tables, page each table with a stable unique order (include an event-unique tie-breaker such as indexed row `id`, not just `timestamp` or `txHash`), freeze the page window against new-head inserts during pagination, dedupe API pages by source event identity, and reconcile local receipt caches to indexed history with a cross-source event merge key (`hash + type + market + assets + shares`, or stronger) rather than raw tx-hash presence.
48. **Paged history source consistency integrity**: when a paged transaction/history read falls back from a primary provider, choose the fallback provider once per query and keep it fixed across all pages. Do not mix providers page-by-page, and do not silently return truncated history when page caps are hit; fail closed with an explicit error instead.
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.
50. **Market-registry fallback integrity**: shared multi-chain market registry reads must preserve per-chain isolation across providers. The primary path may use one shared Monarch/Envio market-list query for first paint, but any chain missing from that result must fall back independently to Morpho per chain and then subgraph per chain. Keep every 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.
53. **Market-registry enrichment boundary integrity**: keep the global market-registry core shell source-agnostic. Do not require provider-specific rolling history fields for first paint, and do not fill those fields with synthetic zeroes in the core registry path. Historical list enrichments such as rolling supply/borrow APYs must resolve through a separate chokepoint keyed by canonical `chainId + market.uniqueKey`, preferably from shared RPC/archive-node snapshot helpers when the primary index source does not expose that history.
54. **Market USD-price provenance integrity**: when market-list USD values are recomputed through shared token-price hooks, keep price provenance separate from the numeric USD value. Only mark a market as having a real USD price, or remove estimated-price UI, when the loan-token price came from a direct chain-scoped price source rather than a peg or hardcoded fallback. If a direct price becomes available after first paint, replace the previously estimated loan-asset USD values at the shared market-processing chokepoint instead of leaving stale estimated values and flags in place.
55. **Token metadata integrity**: chain-scoped token metadata used by market registries must treat `decimals` and `symbol` as one metadata unit. When a token is not in the local registry, resolve both fields through shared batched on-chain reads rather than mixing RPC decimals with placeholder symbols. Manual entries in `src/utils/tokens.ts` must be validated against on-chain `decimals()` and `symbol()` per chain through the shared verifier command, and any intentional display-symbol differences must be captured as explicit overrides instead of silent drift.
56. **Market shell parity integrity**: single-market market-shell fetchers must apply the same canonical registry guards as list fetchers, so blocked or malformed markets cannot re-enter through detail-path reads. In per-chain provider fallback chains, treat empty non-authoritative results the same as provider failure and continue to the next provider instead of short-circuiting fallback for that chain.


### REQUIRED: Regression Rule Capture
Expand Down
15 changes: 8 additions & 7 deletions docs/TECHNICAL_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ Market metrics: Monarch metrics API via `/api/monarch/metrics`
### Dynamic Data (Runtime fetched)
| Data Type | Source | Refresh | Query Hook |
|-----------|--------|---------|------------|
| Markets list | Morpho APIMonarch API → Subgraph | 5 min stale | `useMarketsQuery` |
| Markets list | Monarch multi-chainMorpho per chain → Subgraph per chain | 5 min stale | `useMarketsQuery` |
| Market metrics (flows, trending) | Monarch API | 5 min stale | `useMarketMetricsQuery` |
| 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` |
Expand Down Expand Up @@ -231,8 +231,8 @@ 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 | 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 |
| `useMarketsQuery` | Global market registry used across the app | Monarch multi-chain first, then Morpho per chain, then subgraph per chain | Optional metadata parity from Morpho plus any non-core enrichment we may keep outside the primary registry |
| `useProcessedMarkets` | Blacklist/filtering layer on top of market registry, plus RPC historical-rate enrichment and USD backfill | `useMarketsQuery` + RPC/archive snapshots + `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 | 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 |
Expand Down Expand Up @@ -367,9 +367,10 @@ supportsMorphoApi(network) returns true for:
- Mainnet, Base, Unichain, Polygon, Arbitrum, HyperEVM, Monad

Fallback Strategy:
1. IF supportsMorphoApi(network) → Try Morpho API
2. IF API fails OR unsupported → Try Subgraph
3. Each network fails independently (partial data OK)
1. `useMarketsQuery` tries one shared Monarch market-registry read first
2. Any chain missing from that result falls back independently to Morpho API when supported
3. If Morpho fails or is unsupported, that chain falls back to Subgraph
4. Each network still fails independently (partial data OK)
```

### GraphQL Fetchers
Expand Down Expand Up @@ -414,7 +415,7 @@ Fallback Strategy:

### Key Patterns

1. **Feature-Scoped Priority**: Monarch-first for market detail/history/activity, Morpho-first for the global market registry, Subgraph last
1. **Feature-Scoped Priority**: Monarch-first for market detail/history/activity and the global market registry core shell, Morpho/Subgraph fallback last
2. **Parallel Execution**: `Promise.all()` for multi-network
3. **Graceful Degradation**: Partial data > Error
4. **Three-Phase Market Detail**: Monarch live state + fallback shell + RPC snapshot
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"stylelint": "stylelint '**/*.css' --fix",
"stylelint:check": "stylelint '**/*.css'",
"test:coverage:open": "pnpm test:coverage && open coverage/lcov-report/index.html",
"tokens:verify-metadata": "tsx scripts/verify-token-metadata.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
Expand Down
186 changes: 186 additions & 0 deletions scripts/verify-token-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { createRequire } from 'node:module';
import type { SupportedNetworks as SupportedNetworkId } from '../src/utils/networks';

const moduleRequire = createRequire(import.meta.url);
const { loadEnvConfig } = moduleRequire('@next/env') as {
loadEnvConfig: (dir: string) => void;
};

loadEnvConfig(process.cwd());

const assetExtensionLoader: NodeJS.RequireExtensions[string] = (module, filename) => {
module.exports = filename;
};

for (const extension of ['.png', '.svg', '.webp']) {
moduleRequire.extensions[extension] = assetExtensionLoader;
}

const { supportedTokens, infoToKey, MORPHO_LEGACY } = moduleRequire('../src/utils/tokens') as typeof import('../src/utils/tokens');
const { SupportedNetworks, getDefaultRPC, getNetworkName } = moduleRequire(
'../src/utils/networks',
) as typeof import('../src/utils/networks');
const { fetchOnchainTokenMetadataMap } = moduleRequire('../src/utils/tokenMetadata') as typeof import('../src/utils/tokenMetadata');

type VerificationEntry = {
address: string;
chainId: number;
symbol: string;
decimals: number;
};

type VerificationIssue = {
kind: 'decimals-mismatch' | 'missing-decimals' | 'missing-symbol' | 'symbol-mismatch';
entry: VerificationEntry;
onchainDecimals?: number;
onchainSymbol?: string;
expectedOnchainSymbol?: string;
};

type ChainIssue = {
chainId: number;
message: string;
};

const expectedOnchainSymbolByKey = new Map<string, string>([
[infoToKey('0x35d8949372d46b7a3d5a56006ae77b215fc69bc0', SupportedNetworks.Mainnet), 'bUSD0'],
[infoToKey('0x00000000efe302beaa2b3e6e1b18d08d69a9012a', SupportedNetworks.Mainnet), 'AUSD'],
[infoToKey('0x00000000efe302beaa2b3e6e1b18d08d69a9012a', SupportedNetworks.Monad), 'AUSD'],
[infoToKey('0x8236a87084f8b84306f72007f36f2618a5634494', SupportedNetworks.Mainnet), 'LBTC'],
[infoToKey('0xecac9c5f704e954931349da37f60e39f515c11c1', SupportedNetworks.Base), 'LBTC'],
[infoToKey('0x00b174d66ada7d63789087f50a9b9e0e48446dc1', SupportedNetworks.Base), 'sPINTO'],
[infoToKey('0xb0505e5a99abd03d94a1169e638b78edfed26ea4', SupportedNetworks.Base), 'uSUI'],
[infoToKey('0xc2132d05d31c914a87c6611c10748aeb04b58e8f', SupportedNetworks.Polygon), 'USDT0'],
[infoToKey('0xe7cd86e13ac4309349f30b3435a9d337750fc82d', SupportedNetworks.Monad), 'USDT0'],
[infoToKey(MORPHO_LEGACY, SupportedNetworks.Mainnet), 'MORPHO'],
]);

const getVerificationEntries = (): VerificationEntry[] => {
return supportedTokens.flatMap((token) =>
token.networks.map((network) => ({
address: network.address.toLowerCase(),
chainId: network.chain.id,
symbol: token.symbol,
decimals: token.decimals,
})),
);
};

const formatIssue = (issue: VerificationIssue): string => {
const { address, symbol, decimals } = issue.entry;

switch (issue.kind) {
case 'missing-decimals':
return `${symbol} (${address}): decimals() could not be read on-chain. Configured decimals=${decimals}.`;
case 'decimals-mismatch':
return `${symbol} (${address}): configured decimals=${decimals}, on-chain decimals=${issue.onchainDecimals}.`;
case 'missing-symbol':
return `${symbol} (${address}): symbol() could not be read on-chain. Configured symbol="${symbol}".`;
case 'symbol-mismatch':
return `${symbol} (${address}): configured symbol="${symbol}", expected on-chain symbol="${issue.expectedOnchainSymbol}", actual on-chain symbol="${issue.onchainSymbol}".`;
default: {
const unexpectedKind: never = issue.kind;
throw new Error(`Unknown verification issue kind: ${unexpectedKind}`);
}
}
};

const main = async () => {
const verificationEntries = getVerificationEntries();
const chainConfigIssues: ChainIssue[] = [];
const entriesToVerify = verificationEntries.filter((entry) => {
const rpcUrl = getDefaultRPC(entry.chainId as SupportedNetworkId);
if (rpcUrl) {
return true;
}

if (!chainConfigIssues.some((issue) => issue.chainId === entry.chainId)) {
chainConfigIssues.push({
chainId: entry.chainId,
message: 'RPC is not configured for this network, so on-chain token metadata could not be verified.',
});
}

return false;
});

const metadataByToken = await fetchOnchainTokenMetadataMap(
entriesToVerify.map((entry) => ({
address: entry.address,
chainId: entry.chainId as SupportedNetworkId,
})),
);

const issues: VerificationIssue[] = [];

for (const entry of entriesToVerify) {
const key = infoToKey(entry.address, entry.chainId);
const metadata = metadataByToken.get(key);

if (metadata?.decimals === undefined) {
issues.push({
kind: 'missing-decimals',
entry,
});
} else if (metadata.decimals !== entry.decimals) {
issues.push({
kind: 'decimals-mismatch',
entry,
onchainDecimals: metadata.decimals,
});
}

if (!metadata?.symbol) {
issues.push({
kind: 'missing-symbol',
entry,
});
continue;
}

const expectedOnchainSymbol = expectedOnchainSymbolByKey.get(key) ?? entry.symbol;
if (metadata.symbol !== expectedOnchainSymbol) {
issues.push({
kind: 'symbol-mismatch',
entry,
onchainSymbol: metadata.symbol,
expectedOnchainSymbol,
});
}
}

if (issues.length === 0 && chainConfigIssues.length === 0) {
const networkCount = new Set(verificationEntries.map((entry) => entry.chainId)).size;
console.log(`Verified ${verificationEntries.length} token entries across ${networkCount} networks.`);
return;
}

const issuesByChain = new Map<number, VerificationIssue[]>();
for (const issue of issues) {
const issuesForChain = issuesByChain.get(issue.entry.chainId) ?? [];
issuesForChain.push(issue);
issuesByChain.set(issue.entry.chainId, issuesForChain);
}

console.error(`Token metadata verification failed with ${issues.length + chainConfigIssues.length} issue(s).`);

for (const [chainId, issuesForChain] of Array.from(issuesByChain.entries()).sort((left, right) => left[0] - right[0])) {
console.error(`\n[${getNetworkName(chainId) ?? chainId}]`);
for (const issue of issuesForChain) {
console.error(`- ${formatIssue(issue)}`);
}
}

for (const chainIssue of chainConfigIssues.sort((left, right) => left.chainId - right.chainId)) {
console.error(`\n[${getNetworkName(chainIssue.chainId) ?? chainIssue.chainId}]`);
console.error(`- ${chainIssue.message}`);
}

process.exitCode = 1;
};

main().catch((error: unknown) => {
console.error('Token metadata verification failed with an unexpected error.');
console.error(error);
process.exitCode = 1;
});
9 changes: 2 additions & 7 deletions src/data-sources/monarch-api/historical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,19 +147,14 @@ export const fetchMonarchMarketHistoricalData = async (
limit: MONARCH_HISTORICAL_PAGE_LIMIT,
};

const query =
options.interval === 'HOUR'
? envioMarketHourlySnapshotsQuery
: envioMarketDailySnapshotsQuery;
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 ?? []);
options.interval === 'HOUR' ? (response.data?.MarketHourlySnapshot ?? []) : (response.data?.MarketDailySnapshot ?? []);

return transformSnapshotsToHistoricalResult(snapshots);
} catch (error) {
Expand Down
11 changes: 9 additions & 2 deletions src/data-sources/monarch-api/markets.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Market as BlueMarket, MarketParams as BlueMarketParams } from '@morpho-org/blue-sdk';
import { formatUnits, type Address, zeroAddress } from 'viem';
import { buildEnvioMarketsPageQuery, envioMarketByIdQuery } from '@/graphql/envio-queries';
import { isMarketRegistryEntryAllowed } from '@/utils/markets';
import { getMorphoAddress } from '@/utils/morpho';
import { isSupportedChain, type SupportedNetworks } from '@/utils/networks';
import { blacklistTokens, infoToKey } from '@/utils/tokens';
import { infoToKey } from '@/utils/tokens';
import { resolveTokenInfos, type ResolvedTokenInfo, type TokenAddressInput } from '@/utils/tokenMetadata';
import type { Market, MarketWarning } from '@/utils/types';
import { UNRECOGNIZED_COLLATERAL, UNRECOGNIZED_LOAN } from '@/utils/warnings';
Expand Down Expand Up @@ -134,7 +135,13 @@ const mapMonarchMarketToMarket = (market: MonarchMarketRow, tokenInfos: Map<stri
const loanAssetAddress = normalizeAddress(market.loanToken);
const collateralAssetAddress = normalizeAddress(market.collateralToken);

if (blacklistTokens.includes(loanAssetAddress) || blacklistTokens.includes(collateralAssetAddress)) {
if (
!isMarketRegistryEntryAllowed({
loanAssetAddress,
collateralAssetAddress,
irmAddress: market.irm,
})
) {
return null;
}

Expand Down
Loading