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
14 changes: 7 additions & 7 deletions docs/TECHNICAL_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,14 @@ Market metrics: external data API via `/v1/markets/metrics`
| Vaults list | Morpho API | 5 min | `useAllMorphoVaultsQuery` |
| User autovault metadata | Monarch GraphQL + on-chain enrichment | 60s | `useUserVaultsV2Query` |
| Vault detail/settings metadata | Monarch GraphQL + narrow RPC fallback | 30s | `useVaultV2Data` |
| Vault V2 rewards | Morpho API rewards fields backed by Merkl | 5 min | `useVaultV2RewardsQuery` |
| Vault V2 rewards | Merkl API opportunities via `/api/merkl` | 5 min | `useVaultV2RewardsQuery` |
| Market detail participants/activity | Monarch GraphQL + Morpho API fallback | 2-5 min stale | `useMarketSuppliers` / `useMarketBorrowers` / `useMarketSupplies` / `useMarketBorrows` |
| Vault allocations | On-chain multicall | 30s | `useAllocationsQuery` |
| Token balances | On-chain multicall | 5 min | `useUserBalancesQuery` |
| Oracle metadata | Scanner Gist | 30 min | `useOracleMetadata` / `useAllOracleMetadata` |
| Account contract tags | Kleros Scout API | 6h stale | `useKlerosAddressTagsQuery` |
| User claimable rewards | Merkl API via `/api/merkl` | 5 min stale, forced refresh on manual refetch | `useUserRewardsQuery` |
| Reward campaigns | Merkl API via `/api/merkl` | 5 min stale | `useMerklCampaignsQuery` |
| User claimable rewards | Merkl `/rewards/summary` via `/api/merkl` | 5 min stale, forced refresh on manual refetch | `useUserRewardsQuery` |
| Market reward campaigns | Merkl API via `/api/merkl` | 5 min stale | `useMerklCampaignsQuery` |
| Market liquidations | Monarch GraphQL + Morpho API fallback | 5 min stale | `useMarketLiquidations` |
| Admin stats transactions | Monarch GraphQL + market registry/token price enrichment | 2 min stale | `useMonarchTransactions` |

Expand Down Expand Up @@ -261,7 +261,7 @@ 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 |
| `useVaultV2RewardsQuery` | Vault detail reward APR enrichment | Morpho API `vaultV2ByAddress.rewards` backed by Merkl | Outside Monarch/Envio scope today |
| `useVaultV2RewardsQuery` | Vault detail reward APR enrichment | Merkl API opportunity lookup by vault address through `/api/merkl` | Outside Monarch/Envio scope today |
| `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 |
Expand All @@ -278,7 +278,7 @@ Hooks omitted from this matrix are local-state hooks or pure view/composition he
| `useOracleMetadata` / `useAllOracleMetadata` | Oracle classification and feed metadata | Scanner gist JSON | Not part of Monarch migration |
| `useMarketMetricsQuery` | Enhanced market metrics, flows, growing signal, scores, and current backend market flags | External data API via `/v1/markets/metrics` | Already Monarch-backed; compact discovery flags use `/v1/markets/flags` |
| `useUserRewardsQuery` | User claimable rewards and Merkl proofs | Merkl API through the server-side `/api/merkl` API-key proxy | Outside Monarch/Envio scope today |
| `useMerklCampaignsQuery` / `useMerklHoldIncentivesQuery` | Campaign and HOLD incentive enrichment | Merkl API through `/api/merkl` + hardcoded opportunity mapping | Outside Monarch/Envio scope today |
| `useMerklCampaignsQuery` / `useMerklHoldIncentivesQuery` | Market reward campaign and HOLD incentive enrichment | Merkl API through `/api/merkl` for Morpho campaign data and hardcoded HOLD opportunities | Outside Monarch/Envio scope today |

### Data Flow Patterns

Expand Down Expand Up @@ -444,10 +444,10 @@ Fallback Strategy:
### APIs
| Service | Endpoint | Purpose |
|---------|----------|---------|
| Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions, Vault V2 reward APRs |
| Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions |
| Monarch GraphQL | `https://api.monarchlend.xyz/graphql` | Autovault metadata, market live state, historical charts, market detail/activity, admin transactions |
| Monarch Metrics | External data API `/v1/markets/metrics` | Market metrics and admin stats |
| Merkl API | `https://api.merkl.xyz` via `/api/merkl` | Reward campaigns, opportunities, and user claimable rewards with server-side API-key auth |
| Merkl API | `https://api.merkl.xyz` via `/api/merkl` | Market reward campaigns, Vault V2 reward opportunities, configured HOLD opportunity lookups, and user claimable rewards with server-side API-key auth |
| Velora API | `https://api.paraswap.io` | Swap quotes and executable tx payloads |
| Alchemy | Per-chain RPC | Default RPC provider |

Expand Down
3 changes: 3 additions & 0 deletions docs/VALIDATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ Use this file at the end of non-trivial work. Do not front-load it at task start
- Fallback data should be marked or shaped consistently with primary data so downstream components can reason about it safely.
- Metadata-backed display guards must expose readiness through the shared dependency-status layer, must not treat missing metadata as a negative match, and must preserve the list or previous data while the guard cannot be evaluated.
- 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.
- Market reward campaign display must use Merkl campaign/opportunity data through the shared `/api/merkl` path; do not attach generic HOLD/vault opportunities to Morpho markets unless the campaign identifies a market or an explicit single-token "any Morpho Market" incentive.
- Vault V2 reward APR display must use Merkl opportunities by vault address through `/api/merkl` when Merkl has the reward; do not add a Morpho API reward-field dependency unless Merkl lacks the data.
- User reward claiming must use Merkl `/v4/users/{address}/rewards/summary` claim/proof data; deprecated Morpho URD/non-Merkl reward claim paths and Merkl legacy combined reward endpoints should stay out of the app.
- 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.
Expand Down
50 changes: 0 additions & 50 deletions src/abis/morpho-wrapper.ts

This file was deleted.

106 changes: 106 additions & 0 deletions src/data-sources/merkl/vault-rewards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { fetchMerklApi } from '@/utils/merklApi';
import type { MerklCampaign, MerklOpportunity } from '@/utils/merklTypes';

export type MerklVaultV2Reward = {
supplyApr: number;
asset: {
address: string;
symbol: string;
price?: {
usd?: number | null;
} | null;
};
};

export type MerklVaultV2Rewards = {
address: string;
apy: null;
rewards: MerklVaultV2Reward[];
};

const MERKL_LIVE_STATUS = 'LIVE';
const MERKL_LEND_ACTION = 'LEND';

const isLiveLendOpportunity = (opportunity: MerklOpportunity): boolean => {
const status = opportunity.status?.toUpperCase();
const action = opportunity.action?.toUpperCase();
const apr = opportunity.apr;
const hasLiveCampaigns = opportunity.liveCampaigns == null || opportunity.liveCampaigns > 0;

return (
status === MERKL_LIVE_STATUS &&
action === MERKL_LEND_ACTION &&
hasLiveCampaigns &&
typeof apr === 'number' &&
Number.isFinite(apr) &&
apr > 0
);
};

const isActiveRewardCampaign = (campaign: MerklCampaign): boolean => {
const now = Math.floor(Date.now() / 1000);
return campaign.startTimestamp <= now && campaign.endTimestamp > now && Number.isFinite(campaign.apr) && campaign.apr > 0;
};

const getCampaignRewards = (campaigns: MerklCampaign[] | undefined): MerklVaultV2Reward[] => {
const rewardsByToken = new Map<string, MerklVaultV2Reward>();

for (const campaign of campaigns ?? []) {
if (!isActiveRewardCampaign(campaign)) {
continue;
}

const token = campaign.rewardToken;
if (!token?.address || !token.symbol) {
continue;
}

const key = `${campaign.computeChainId}:${token.address.toLowerCase()}`;
const existing = rewardsByToken.get(key);
const supplyApr = campaign.apr / 100;

if (existing) {
existing.supplyApr += supplyApr;
continue;
}

rewardsByToken.set(key, {
supplyApr,
asset: {
address: token.address,
symbol: token.symbol,
price: typeof token.price === 'number' ? { usd: token.price } : null,
},
});
}

return Array.from(rewardsByToken.values());
};

export const fetchMerklVaultV2Rewards = async (vaultAddress: string, chainId: number): Promise<MerklVaultV2Rewards | null> => {
try {
const { data, error, status } = await fetchMerklApi<MerklOpportunity[]>('/v4/opportunities', {
mainProtocolId: 'morpho',
chainId,
explorerAddress: vaultAddress,
campaigns: true,
});

if (error || status !== 200) {
throw new Error(`Merkl vault rewards fetch failed: ${status} ${error ?? ''}`.trim());
}

const rewards = (Array.isArray(data) ? data : [])
.filter(isLiveLendOpportunity)
.flatMap((opportunity) => getCampaignRewards(opportunity.campaigns));

return {
address: vaultAddress,
apy: null,
rewards,
};
} catch (error) {
console.warn('Error fetching Merkl V2 vault rewards:', error);
return null;
}
};
52 changes: 1 addition & 51 deletions src/data-sources/morpho-api/vaults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MORPHO_API_SUPPORTED_NETWORKS, supportsMorphoApiChainId } from '@/config/dataSources';
import { allVaultsQuery, vaultApysQuery, vaultV2MetadataQuery, vaultV2RewardsQuery } from '@/graphql/vault-queries';
import { allVaultsQuery, vaultApysQuery, vaultV2MetadataQuery } from '@/graphql/vault-queries';
import { morphoGraphqlFetcher } from './fetchers';

export type VaultAddressByNetwork = {
Expand Down Expand Up @@ -38,23 +38,6 @@ export type MorphoVaultV2Metadata = {
symbol: string;
};

export type MorphoVaultV2Reward = {
supplyApr: number;
asset: {
address: string;
symbol: string;
price?: {
usd?: number | null;
} | null;
};
};

export type MorphoVaultV2Rewards = {
address: string;
apy: number | null;
rewards: MorphoVaultV2Reward[];
};

// API response types
type ApiVault = {
address: string;
Expand Down Expand Up @@ -125,13 +108,6 @@ type VaultV2MetadataApiResponse = {
errors?: { message: string }[];
};

type VaultV2RewardsApiResponse = {
data?: {
vaultV2ByAddress?: MorphoVaultV2Rewards | null;
};
errors?: { message: string }[];
};

const getVaultApyKey = (address: string, chainId: number) => `${address.toLowerCase()}-${chainId}`;
const getVaultNetworkId = (vault: VaultAddressByNetwork) => vault.chainId ?? vault.networkId;
const getSupportedVaultNetworkId = (vault: VaultAddressByNetwork) => {
Expand Down Expand Up @@ -316,32 +292,6 @@ export const fetchListedMorphoVaultV2Metadata = async (): Promise<MorphoVaultV2M
}
};

export const fetchMorphoVaultV2Rewards = async (vaultAddress: string, chainId: number): Promise<MorphoVaultV2Rewards | null> => {
if (!supportsMorphoApiChainId(chainId)) {
return null;
}

try {
const response = await morphoGraphqlFetcher<VaultV2RewardsApiResponse>(vaultV2RewardsQuery, {
address: vaultAddress.toLowerCase(),
chainId,
});

const vault = response?.data?.vaultV2ByAddress;
if (!vault) {
return null;
}

return {
...vault,
rewards: Array.isArray(vault.rewards) ? vault.rewards : [],
};
} catch (error) {
console.warn('Error fetching Morpho V2 vault rewards:', error);
return null;
}
};

export const fetchMorphoVaultApys = async (vaults: VaultAddressByNetwork[]): Promise<Map<string, number>> => {
if (vaults.length === 0) {
return new Map();
Expand Down
18 changes: 14 additions & 4 deletions src/features/markets/components/market-details-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getIRMTitle, previewMarketState } from '@/utils/morpho';
import { getTruncatedAssetName } from '@/utils/oracle';
import { convertApyToApr } from '@/utils/rateMath';
import type { Market } from '@/utils/types';
import { isBorrowCampaign } from '@/utils/merklApi';
import OracleVendorBadge from './oracle-vendor-badge';
import { TokenIcon } from '@/components/shared/token-icon';

Expand Down Expand Up @@ -50,6 +51,15 @@ export function MarketDetailsBlock({
chainId: market.morphoBlue.chain.id,
whitelisted: market.whitelisted,
});
const modeCampaigns = useMemo(
() =>
activeCampaigns.filter((campaign) => {
const isBorrow = isBorrowCampaign(campaign);
return mode === 'borrow' ? isBorrow : !isBorrow;
}),
[activeCampaigns, mode],
);
const hasModeRewards = hasActiveRewards && modeCampaigns.length > 0;

// Calculate preview state when supplyDelta or borrowDelta is provided
const previewState = useMemo(() => {
Expand Down Expand Up @@ -208,16 +218,16 @@ export function MarketDetailsBlock({
</p>
)}
</div>
{showRewards && hasActiveRewards && (
{showRewards && hasModeRewards && (
<div className="flex items-start justify-between">
<p className="flex items-center gap-1 font-zen text-sm opacity-50">Extra Rewards:</p>
<div className="flex items-center gap-1">
<p className="text-right text-sm font-bold text-green-600 dark:text-green-400">
+{activeCampaigns.reduce((sum, c) => sum + c.apr, 0).toFixed(2)}%
+{modeCampaigns.reduce((sum, c) => sum + c.apr, 0).toFixed(2)}%
</p>
{activeCampaigns.map((campaign, index) => (
{modeCampaigns.map((campaign) => (
<TokenIcon
key={index}
key={campaign.campaignId}
address={campaign.rewardToken.address}
chainId={campaign.chainId}
symbol={campaign.rewardToken.symbol}
Expand Down
3 changes: 2 additions & 1 deletion src/features/markets/components/rewards-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Image from 'next/image';
import { FiGift } from 'react-icons/fi';
import { TooltipContent } from '@/components/shared/tooltip-content';
import { useMarketCampaigns } from '@/hooks/useMarketCampaigns';
import { isBorrowCampaign } from '@/utils/merklApi';
import merklLogo from '@/imgs/merkl.jpg';

type RewardsIndicatorProps = {
Expand All @@ -28,7 +29,7 @@ export function RewardsIndicator({ marketId, chainId, loanTokenAddress, whitelis
// Create tooltip detail with all rewards
const rewardsList = activeCampaigns
.map((campaign) => {
const rewardType = campaign.type === 'MORPHOBORROW' ? 'borrower' : 'supplier';
const rewardType = isBorrowCampaign(campaign) ? 'borrower' : 'supplier';
return `${campaign.rewardToken.symbol} ${rewardType} reward +${campaign.apr.toFixed(2)}%`;
})
.join('\n');
Expand Down
Loading