From bd06d2bf235941b6d309c30a723b01862c498cfd Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 12 Jun 2026 03:16:37 +0800 Subject: [PATCH 1/3] Fix Merkl market rewards --- docs/TECHNICAL_OVERVIEW.md | 6 +- docs/VALIDATIONS.md | 3 + src/abis/morpho-wrapper.ts | 50 ------ .../components/campaign-badge.tsx | 10 +- .../components/campaign-modal.tsx | 37 ++--- .../components/apy-breakdown-tooltip.tsx | 21 +-- .../components/market-details-block.tsx | 16 +- .../markets/components/rewards-indicator.tsx | 6 +- src/features/rewards/rewards-view.tsx | 63 +------ src/hooks/queries/useMerklCampaignsQuery.ts | 49 +++--- src/hooks/useMarketCampaigns.ts | 14 +- src/hooks/useWrapLegacyMorpho.ts | 97 ----------- src/utils/merklApi.ts | 154 ++++++++---------- src/utils/merklTypes.ts | 85 +++++----- src/utils/tokens.ts | 4 - 15 files changed, 192 insertions(+), 423 deletions(-) delete mode 100644 src/abis/morpho-wrapper.ts delete mode 100644 src/hooks/useWrapLegacyMorpho.ts diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index eed2c78ab..4d2b64f8e 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -219,7 +219,7 @@ Market metrics: external data API via `/v1/markets/metrics` | 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` | +| 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` | @@ -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 @@ -447,7 +447,7 @@ Fallback Strategy: | Morpho API | `https://blue-api.morpho.org/graphql` | Markets, vaults, positions, Vault V2 reward APRs | | 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, 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 | diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index 284e23282..a29a182ce 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -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 reward APR display may use Morpho API vault reward fields (`vaultV2ByAddress.rewards` or documented successors) when the feature is scoped to Vault V2 rewards. +- User reward claiming must use direct Merkl API claim/proof data; deprecated Morpho URD/non-Merkl reward claim paths 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. diff --git a/src/abis/morpho-wrapper.ts b/src/abis/morpho-wrapper.ts deleted file mode 100644 index 53e2aa4b7..000000000 --- a/src/abis/morpho-wrapper.ts +++ /dev/null @@ -1,50 +0,0 @@ -export default [ - { - inputs: [{ internalType: 'address', name: 'morphoToken', type: 'address' }], - stateMutability: 'nonpayable', - type: 'constructor', - }, - { inputs: [], name: 'SelfAddress', type: 'error' }, - { inputs: [], name: 'ZeroAddress', type: 'error' }, - { - inputs: [], - name: 'LEGACY_MORPHO', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'NEW_MORPHO', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [ - { internalType: 'address', name: 'account', type: 'address' }, - { internalType: 'uint256', name: 'value', type: 'uint256' }, - ], - name: 'depositFor', - outputs: [{ internalType: 'bool', name: '', type: 'bool' }], - stateMutability: 'nonpayable', - type: 'function', - }, - { - inputs: [], - name: 'underlying', - outputs: [{ internalType: 'address', name: '', type: 'address' }], - stateMutability: 'pure', - type: 'function', - }, - { - inputs: [ - { internalType: 'address', name: 'account', type: 'address' }, - { internalType: 'uint256', name: 'value', type: 'uint256' }, - ], - name: 'withdrawTo', - outputs: [{ internalType: 'bool', name: '', type: 'bool' }], - stateMutability: 'nonpayable', - type: 'function', - }, -] as const; diff --git a/src/features/market-detail/components/campaign-badge.tsx b/src/features/market-detail/components/campaign-badge.tsx index 230a93b1c..8c1a8adff 100644 --- a/src/features/market-detail/components/campaign-badge.tsx +++ b/src/features/market-detail/components/campaign-badge.tsx @@ -14,11 +14,6 @@ type CampaignBadgeProps = { filterType?: 'supply' | 'borrow'; }; -// Supply campaign types: MORPHOSUPPLY, MORPHOSUPPLY_SINGLETOKEN, MULTILENDBORROW -const SUPPLY_CAMPAIGN_TYPES = ['MORPHOSUPPLY', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; -// Borrow campaign types: MORPHOBORROW -const BORROW_CAMPAIGN_TYPES = ['MORPHOBORROW']; - export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted, filterType }: CampaignBadgeProps) { const [isModalOpen, setIsModalOpen] = useState(false); @@ -33,8 +28,9 @@ export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted const filteredCampaigns = useMemo(() => { if (!filterType) return activeCampaigns; - const allowedTypes = filterType === 'supply' ? SUPPLY_CAMPAIGN_TYPES : BORROW_CAMPAIGN_TYPES; - return activeCampaigns.filter((campaign) => allowedTypes.includes(campaign.type)); + return activeCampaigns.filter((campaign) => + filterType === 'borrow' ? campaign.type === 'MORPHOBORROW' : campaign.type !== 'MORPHOBORROW', + ); }, [activeCampaigns, filterType]); if (loading || !hasActiveRewards || filteredCampaigns.length === 0) { diff --git a/src/features/market-detail/components/campaign-modal.tsx b/src/features/market-detail/components/campaign-modal.tsx index 64241c1b1..4595242bf 100644 --- a/src/features/market-detail/components/campaign-modal.tsx +++ b/src/features/market-detail/components/campaign-modal.tsx @@ -1,18 +1,18 @@ 'use client'; import { ExternalLinkIcon } from '@radix-ui/react-icons'; -import Image from 'next/image'; import Link from 'next/link'; +import { TokenIcon } from '@/components/shared/token-icon'; import { Button } from '@/components/ui/button'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { getMerklCampaignURL } from '@/utils/external'; -import type { SimplifiedCampaign, MerklCampaignType } from '@/utils/merklTypes'; +import type { SimplifiedCampaign, MarketRewardType } from '@/utils/merklTypes'; -const CAMPAIGN_TYPE_CONFIG: Record = { +const CAMPAIGN_TYPE_CONFIG: Record = { MORPHOSUPPLY: { badge: 'Lender Rewards' }, - MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards' }, - MULTILENDBORROW: { badge: 'Lend/Borrow Rewards' }, MORPHOBORROW: { badge: 'Borrow Rewards' }, + MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards' }, + MULTILENDBORROW: { badge: 'Market Rewards' }, }; type CampaignModalProps = { @@ -22,17 +22,15 @@ type CampaignModalProps = { }; function getUrlIdentifier(campaign: SimplifiedCampaign): string { - // Always prefer opportunityIdentifier from the Opportunity object if (campaign.opportunityIdentifier) { return campaign.opportunityIdentifier; } - // Fallback for legacy data - switch (campaign.type) { - case 'MORPHOSUPPLY_SINGLETOKEN': - return campaign.targetToken?.address ?? campaign.campaignId; - default: - return campaign.marketId.slice(0, 42); + + if (campaign.type === 'MORPHOSUPPLY_SINGLETOKEN') { + return campaign.targetToken?.address ?? campaign.campaignId; } + + return campaign.marketId.slice(0, 42); } function formatCampaignDate(timestamp: number): string { @@ -44,8 +42,7 @@ function formatCampaignDate(timestamp: number): string { } function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) { - const urlIdentifier = getUrlIdentifier(campaign); - const merklUrl = getMerklCampaignURL(campaign.chainId, campaign.type, urlIdentifier); + const merklUrl = getMerklCampaignURL(campaign.chainId, campaign.type, getUrlIdentifier(campaign)); const { badge } = CAMPAIGN_TYPE_CONFIG[campaign.type] ?? CAMPAIGN_TYPE_CONFIG.MORPHOSUPPLY; @@ -56,12 +53,12 @@ function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) {
{badge}
- {campaign.rewardToken.symbol} {campaign.rewardToken.symbol}
@@ -75,7 +72,7 @@ function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) { {/* Date Range + Link */}
- {formatCampaignDate(campaign.startTimestamp)} — {formatCampaignDate(campaign.endTimestamp)} + {formatCampaignDate(campaign.startTimestamp)} - {formatCampaignDate(campaign.endTimestamp)} } onClose={onClose} /> diff --git a/src/features/markets/components/apy-breakdown-tooltip.tsx b/src/features/markets/components/apy-breakdown-tooltip.tsx index 576d49ac8..1d6abeef1 100644 --- a/src/features/markets/components/apy-breakdown-tooltip.tsx +++ b/src/features/markets/components/apy-breakdown-tooltip.tsx @@ -24,27 +24,8 @@ type APYCellProps = { mode?: RateMode; }; -const modeByType: Record = { - MORPHOSUPPLY: 'supply', - MORPHOSUPPLY_SINGLETOKEN: 'supply', - MORPHOBORROW: 'borrow', -}; - const getCampaignMode = (campaign: SimplifiedCampaign): RateMode | null => { - const directMode = modeByType[campaign.type]; - if (directMode) return directMode; - - if (campaign.type !== 'MULTILENDBORROW') return null; - - const action = campaign.opportunityAction?.toUpperCase(); - if (action === 'LEND') return 'supply'; - if (action === 'BORROW') return 'borrow'; - - const name = campaign.name?.toLowerCase() ?? ''; - if (name.includes('borrow')) return 'borrow'; - if (name.includes('supply') || name.includes('lend')) return 'supply'; - - return null; + return campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' ? 'borrow' : 'supply'; }; const filterCampaignsByMode = (campaigns: SimplifiedCampaign[], mode: RateMode): SimplifiedCampaign[] => { diff --git a/src/features/markets/components/market-details-block.tsx b/src/features/markets/components/market-details-block.tsx index 1e94c6f45..fac88c571 100644 --- a/src/features/markets/components/market-details-block.tsx +++ b/src/features/markets/components/market-details-block.tsx @@ -50,6 +50,14 @@ export function MarketDetailsBlock({ chainId: market.morphoBlue.chain.id, whitelisted: market.whitelisted, }); + const modeCampaigns = useMemo( + () => + activeCampaigns.filter((campaign) => + mode === 'borrow' ? campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' : campaign.type !== 'MORPHOBORROW', + ), + [activeCampaigns, mode], + ); + const hasModeRewards = hasActiveRewards && modeCampaigns.length > 0; // Calculate preview state when supplyDelta or borrowDelta is provided const previewState = useMemo(() => { @@ -208,16 +216,16 @@ export function MarketDetailsBlock({

)}
- {showRewards && hasActiveRewards && ( + {showRewards && hasModeRewards && (

Extra Rewards:

- +{activeCampaigns.reduce((sum, c) => sum + c.apr, 0).toFixed(2)}% + +{modeCampaigns.reduce((sum, c) => sum + c.apr, 0).toFixed(2)}%

- {activeCampaigns.map((campaign, index) => ( + {modeCampaigns.map((campaign) => ( { - const rewardType = campaign.type === 'MORPHOBORROW' ? 'borrower' : 'supplier'; + const rewardType = campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' ? 'borrower' : 'supplier'; return `${campaign.rewardToken.symbol} ${rewardType} reward +${campaign.apr.toFixed(2)}%`; }) .join('\n'); diff --git a/src/features/rewards/rewards-view.tsx b/src/features/rewards/rewards-view.tsx index 481526b5f..a69a8b67b 100644 --- a/src/features/rewards/rewards-view.tsx +++ b/src/features/rewards/rewards-view.tsx @@ -13,12 +13,10 @@ import { Tooltip } from '@/components/ui/tooltip'; import { RiBookmarkFill, RiBookmarkLine } from 'react-icons/ri'; import { TooltipContent } from '@/components/shared/tooltip-content'; import { useUserRewardsQuery } from '@/hooks/queries/useUserRewardsQuery'; - -import { useWrapLegacyMorpho } from '@/hooks/useWrapLegacyMorpho'; import { usePortfolioBookmarks } from '@/stores/usePortfolioBookmarks'; import { formatBalance, formatSimple } from '@/utils/balance'; import { SupportedNetworks } from '@/utils/networks'; -import { MORPHO_LEGACY, MORPHO_TOKEN_BASE, MORPHO_TOKEN_MAINNET } from '@/utils/tokens'; +import { MORPHO_TOKEN_BASE, MORPHO_TOKEN_MAINNET } from '@/utils/tokens'; import type { AggregatedRewardType } from '@/utils/types'; import RewardTable from './components/reward-table'; import { PositionBreadcrumbs } from '@/features/position-detail/components/position-breadcrumbs'; @@ -45,25 +43,13 @@ export default function Rewards() { chainId: SupportedNetworks.Base, }); - const { data: morphoBalanceLegacy, refetch: refetchLegacy } = useReadContract({ - address: MORPHO_LEGACY, - abi: erc20Abi, - functionName: 'balanceOf', - args: [account as Address], - chainId: SupportedNetworks.Mainnet, - }); - const handleRefresh = useCallback(() => { void refetch(); void refetchMainnet(); void refetchBase(); - void refetchLegacy(); - }, [refetch, refetchMainnet, refetchBase, refetchLegacy]); + }, [refetch, refetchMainnet, refetchBase]); - const morphoBalance = useMemo( - () => (morphoBalanceMainnet ?? 0n) + (morphoBalanceBase ?? 0n) + (morphoBalanceLegacy ?? 0n), - [morphoBalanceMainnet, morphoBalanceBase, morphoBalanceLegacy], - ); + const morphoBalance = useMemo(() => (morphoBalanceMainnet ?? 0n) + (morphoBalanceBase ?? 0n), [morphoBalanceMainnet, morphoBalanceBase]); const allRewards = useMemo(() => { const result: AggregatedRewardType[] = []; @@ -94,8 +80,7 @@ export default function Rewards() { return allRewards.reduce((acc, reward) => { if ( reward.asset.address.toLowerCase() === MORPHO_TOKEN_MAINNET.toLowerCase() || - reward.asset.address.toLowerCase() === MORPHO_TOKEN_BASE.toLowerCase() || - reward.asset.address.toLowerCase() === MORPHO_LEGACY.toLowerCase() + reward.asset.address.toLowerCase() === MORPHO_TOKEN_BASE.toLowerCase() ) { return acc + reward.total.claimable; } @@ -103,14 +88,8 @@ export default function Rewards() { }, 0n); }, [allRewards]); - const showLegacy = morphoBalanceLegacy !== undefined && morphoBalanceLegacy !== 0n; const isBookmarked = isAddressBookmarked(account as Address); - const { wrap, transaction } = useWrapLegacyMorpho(morphoBalanceLegacy ?? 0n, () => { - // Refresh rewards data after successful wrap - handleRefresh(); - }); - useEffect(() => { if (account) { addVisitedAddress(account); @@ -201,40 +180,6 @@ export default function Rewards() {
- - {showLegacy && ( -
- - } - > - - Legacy MORPHO - - -
- {morphoBalanceLegacy && {formatSimple(formatBalance(morphoBalanceLegacy, 18))}} - - -
-
- )}
diff --git a/src/hooks/queries/useMerklCampaignsQuery.ts b/src/hooks/queries/useMerklCampaignsQuery.ts index 056f9d29e..b952b0213 100644 --- a/src/hooks/queries/useMerklCampaignsQuery.ts +++ b/src/hooks/queries/useMerklCampaignsQuery.ts @@ -1,10 +1,28 @@ import { useQuery } from '@tanstack/react-query'; import { useDeferredQueryEnable } from '@/hooks/useDeferredQueryEnable'; -import { fetchActiveCampaigns, simplifyMerklCampaign, expandMultiLendBorrowCampaign } from '@/utils/merklApi'; -import type { SimplifiedCampaign, MerklCampaignType } from '@/utils/merklTypes'; +import { expandMultiLendBorrowCampaign, fetchActiveCampaigns, simplifyMerklCampaign } from '@/utils/merklApi'; +import type { MerklCampaignType, SimplifiedCampaign } from '@/utils/merklTypes'; const CAMPAIGN_TYPES_TO_FETCH: MerklCampaignType[] = ['MORPHOSUPPLY', 'MORPHOBORROW', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; +const toSimplifiedCampaigns = ( + type: MerklCampaignType, + campaigns: Awaited>, +): SimplifiedCampaign[] => + campaigns.flatMap((campaign) => { + const typedCampaign = { + ...campaign, + type, + }; + + if (type === 'MULTILENDBORROW') { + return expandMultiLendBorrowCampaign(typedCampaign); + } + + const simplified = simplifyMerklCampaign(typedCampaign); + return simplified ? [simplified] : []; + }); + export const useMerklCampaignsQuery = () => { const enabled = useDeferredQueryEnable(true, true, 2000); const query = useQuery({ @@ -12,29 +30,16 @@ export const useMerklCampaignsQuery = () => { queryFn: async () => { const settledResults = await Promise.allSettled(CAMPAIGN_TYPES_TO_FETCH.map((type) => fetchActiveCampaigns({ type }))); - // Extract successful results, use empty array for failed fetches - const results = settledResults.map((result, index) => { + return settledResults.flatMap((result, index) => { + const type = CAMPAIGN_TYPES_TO_FETCH[index]; + if (!type) return []; + if (result.status === 'fulfilled') { - return result.value; + return toSimplifiedCampaigns(type, result.value); } - console.warn(`Failed to fetch ${CAMPAIGN_TYPES_TO_FETCH[index]} campaigns:`, result.reason); - return []; - }); - // Hot Fix: the returned format changed and type no longer in current form. Insert it back - const allRawCampaigns = results.flatMap((campaigns, index) => - campaigns.map((campaign) => ({ - ...campaign, - type: CAMPAIGN_TYPES_TO_FETCH[index], - })), - ); - - // Expand MULTILENDBORROW campaigns into multiple SimplifiedCampaign objects (one per market) - return allRawCampaigns.flatMap((campaign) => { - if (campaign.type === 'MULTILENDBORROW') { - return expandMultiLendBorrowCampaign(campaign); - } - return simplifyMerklCampaign(campaign); + console.warn(`Failed to fetch ${type} campaigns:`, result.reason); + return []; }); }, staleTime: 5 * 60 * 1000, diff --git a/src/hooks/useMarketCampaigns.ts b/src/hooks/useMarketCampaigns.ts index 6d5e6a423..d99b8ad3b 100644 --- a/src/hooks/useMarketCampaigns.ts +++ b/src/hooks/useMarketCampaigns.ts @@ -2,14 +2,13 @@ import { useMemo } from 'react'; import type { SimplifiedCampaign } from '@/utils/merklTypes'; import { useMerklCampaignsQuery } from './queries/useMerklCampaignsQuery'; -// Blacklisted campaign IDs - these will be filtered out const BLACKLISTED_CAMPAIGN_IDS: string[] = [ // Seems to be reporting bad APY, not singleton for all market for sure // https://app.merkl.xyz/opportunities/base/MORPHOSUPPLY_SINGLETOKEN/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 '0x4b5aa0f66eb6a63e3b761de8fbbcc8154d568086c1234ba58516f3263a79200a', // https://app.merkl.xyz/opportunities/base/MORPHOSUPPLY_SINGLETOKEN/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913WHITELIST_PER_PROTOCOL '0x97380b45eed593b3108275de15ba89e452eaffeb04f0ffc7d8f131cf8c70f7a3', - '0x515f512312edec4254029e0696fc0df3862dcbf1a18e9e8e345f7c52ec528b0a', // mainnet + '0x515f512312edec4254029e0696fc0df3862dcbf1a18e9e8e345f7c52ec528b0a', ]; type UseMarketCampaignsReturn = { @@ -35,11 +34,10 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa const result = useMemo(() => { const normalizedMarketId = marketId.toLowerCase(); - // Filter campaigns for this specific market - const directMarketCampaigns = allCampaigns.filter((campaign) => campaign.marketId?.toLowerCase() === normalizedMarketId); + const directMarketCampaigns = allCampaigns.filter( + (campaign) => campaign.chainId === chainId && campaign.marketId?.toLowerCase() === normalizedMarketId, + ); - // For SINGLETOKEN campaigns, also include campaigns where the loan token matches the target token - // the market has to be whitelisted const singleTokenCampaigns = loanTokenAddress && chainId && whitelisted ? allCampaigns.filter( @@ -50,13 +48,9 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa ) : []; - // Combine both types of campaigns and filter out blacklisted ones const allMarketCampaigns = [...directMarketCampaigns, ...singleTokenCampaigns].filter((campaign) => { if (BLACKLISTED_CAMPAIGN_IDS.includes(campaign.campaignId.toLowerCase())) return false; - - // temp: remove all Morpho Vault V2 campaigns until we find better way to filter if (campaign.name?.includes('Morpho Vault V2')) return false; - return true; }); diff --git a/src/hooks/useWrapLegacyMorpho.ts b/src/hooks/useWrapLegacyMorpho.ts deleted file mode 100644 index 642d8920e..000000000 --- a/src/hooks/useWrapLegacyMorpho.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback } from 'react'; -import { toast } from 'react-toastify'; -import { type Address, encodeFunctionData } from 'viem'; -import { useConnection, useSwitchChain } from 'wagmi'; - -import wrapperABI from '@/abis/morpho-wrapper'; -import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; -import { useTransactionTracking } from '@/hooks/useTransactionTracking'; -import { SupportedNetworks } from '@/utils/networks'; -import { MORPHO_LEGACY, MORPHO_TOKEN_WRAPPER } from '@/utils/tokens'; -import { useERC20Approval } from './useERC20Approval'; - -export type WrapStep = 'approve' | 'wrap'; - -const WRAP_STEPS = [ - { id: 'approve', title: 'Approve MORPHO', description: 'Approve legacy MORPHO tokens for wrapping' }, - { id: 'wrap', title: 'Wrap MORPHO', description: 'Confirm transaction to wrap your MORPHO tokens' }, -]; - -export function useWrapLegacyMorpho(amount: bigint, onSuccess?: () => void) { - const tracking = useTransactionTracking('wrap'); - - const { address: account, chainId } = useConnection(); - const { switchChainAsync } = useSwitchChain(); - - const { isApproved, approve } = useERC20Approval({ - token: MORPHO_LEGACY as Address, - spender: MORPHO_TOKEN_WRAPPER as Address, - amount, - tokenSymbol: 'MORPHO', - chainId: SupportedNetworks.Mainnet, - }); - - const { sendTransactionAsync } = useTransactionWithToast({ - toastId: 'wrap-morpho', - pendingText: 'Wrapping MORPHO...', - successText: 'Successfully wrapped MORPHO tokens!', - errorText: 'Failed to wrap MORPHO tokens', - chainId: SupportedNetworks.Mainnet, - onSuccess: () => { - tracking.complete(); - onSuccess?.(); - }, - }); - - const wrap = useCallback(async () => { - try { - if (!account) { - toast.error('Wallet not connected'); - return; - } - - if (chainId !== SupportedNetworks.Mainnet) { - await switchChainAsync({ chainId: SupportedNetworks.Mainnet }); - toast.info('Network changed'); - } - - tracking.start( - WRAP_STEPS, - { - title: 'Wrap MORPHO', - description: 'Wrapping legacy MORPHO tokens', - tokenSymbol: 'MORPHO', - amount, - }, - 'approve', - ); - - if (!isApproved) { - await approve(); - } - - tracking.update('wrap'); - await sendTransactionAsync({ - account: account, - to: MORPHO_TOKEN_WRAPPER, - data: encodeFunctionData({ - abi: wrapperABI, - functionName: 'depositFor', - args: [account, amount], - }), - }); - } catch (_err) { - toast.error('Failed to wrap MORPHO.'); - tracking.fail(); - } - }, [account, amount, chainId, isApproved, approve, sendTransactionAsync, switchChainAsync, tracking]); - - return { - wrap, - // Transaction tracking - transaction: tracking.transaction, - dismiss: tracking.dismiss, - currentStep: tracking.currentStep as WrapStep | null, - isApproved, - }; -} diff --git a/src/utils/merklApi.ts b/src/utils/merklApi.ts index 1517b9435..a19660f58 100644 --- a/src/utils/merklApi.ts +++ b/src/utils/merklApi.ts @@ -1,4 +1,4 @@ -import type { MerklCampaign, SimplifiedCampaign, MerklApiParams, MerklOpportunityLookupParams, MerklOpportunity } from './merklTypes'; +import type { MerklApiParams, MerklCampaign, MerklOpportunityLookupParams, MerklOpportunity, SimplifiedCampaign } from './merklTypes'; const MERKL_API_PROXY_BASE_PATH = '/api/merkl'; @@ -56,44 +56,36 @@ export async function fetchMerklApi( }; } -// Helper function to fetch campaigns from the Merkl REST API. export async function fetchCampaigns(params: MerklApiParams = {}): Promise { - try { - const queryParams: Record = {}; - - if (params.type) queryParams.type = params.type; - if (params.chainId !== undefined) queryParams.chainId = params.chainId; - if (params.items !== undefined) queryParams.items = params.items; - if (params.page !== undefined) queryParams.page = params.page; - if (params.startTimestamp !== undefined) queryParams.startTimestamp = params.startTimestamp; - if (params.endTimestamp !== undefined) queryParams.endTimestamp = params.endTimestamp; - - const { data, error, status } = await fetchMerklApi('/v4/campaigns', { - ...queryParams, - mainProtocolId: 'morpho', - withOpportunity: true, - }); - - if (error || status !== 200) { - throw new Error(`Merkl API error: ${status} ${error}`); - } - - return Array.isArray(data) ? data : []; - } catch (err) { - console.error('Error fetching Merkl campaigns:', err); - throw err; + const queryParams: Record = {}; + + if (params.type) queryParams.type = params.type; + if (params.chainId !== undefined) queryParams.chainId = params.chainId; + if (params.items !== undefined) queryParams.items = params.items; + if (params.page !== undefined) queryParams.page = params.page; + if (params.startTimestamp !== undefined) queryParams.startTimestamp = params.startTimestamp; + if (params.endTimestamp !== undefined) queryParams.endTimestamp = params.endTimestamp; + + const { data, error, status } = await fetchMerklApi('/v4/campaigns', { + ...queryParams, + mainProtocolId: 'morpho', + withOpportunity: true, + }); + + if (error || status !== 200) { + throw new Error(`Merkl API campaign error: ${status} ${error}`); } + + return Array.isArray(data) ? data : []; } -// Helper function to fetch active campaigns with full pagination export async function fetchActiveCampaigns(params: Omit = {}): Promise { const now = Math.floor(Date.now() / 1000); - const pageSize = params.items ?? 100; // Use provided items or default to 100 + const pageSize = params.items ?? 100; const allCampaigns: MerklCampaign[] = []; let currentPage = 0; - let hasMore = true; - while (hasMore) { + while (true) { const batch = await fetchCampaigns({ ...params, items: pageSize, @@ -104,12 +96,11 @@ export async function fetchActiveCampaigns(params: Omit { const now = Math.floor(Date.now() / 1000); - return campaign.startTimestamp <= now && campaign.endTimestamp > now; -} + return campaign.startTimestamp <= now && campaign.endTimestamp > now && Number.isFinite(campaign.apr) && campaign.apr > 0; +}; -// Helper to extract common campaign fields -function getBaseCampaignFields( +const getBaseCampaignFields = ( campaign: MerklCampaign, ): Pick< SimplifiedCampaign, @@ -185,65 +174,66 @@ function getBaseCampaignFields( | 'name' | 'opportunityIdentifier' | 'opportunityAction' -> { - return { - chainId: campaign.computeChainId, - campaignId: campaign.campaignId, - type: campaign.type, - apr: campaign.apr, - rewardToken: { - symbol: campaign.rewardToken.symbol, - icon: campaign.rewardToken.icon, - address: campaign.rewardToken.address, - }, - startTimestamp: campaign.startTimestamp, - endTimestamp: campaign.endTimestamp, - isActive: isCampaignActive(campaign), - name: campaign.Opportunity?.name, - opportunityIdentifier: campaign.Opportunity?.identifier, - opportunityAction: campaign.Opportunity?.action, - }; -} - -// Adapter function to convert Merkl campaigns to SimplifiedCampaign. -export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign { +> => ({ + chainId: campaign.computeChainId, + campaignId: campaign.campaignId, + type: campaign.type, + apr: campaign.apr, + rewardToken: { + symbol: campaign.rewardToken.symbol, + icon: campaign.rewardToken.icon, + address: campaign.rewardToken.address, + }, + startTimestamp: campaign.startTimestamp, + endTimestamp: campaign.endTimestamp, + isActive: isCampaignActive(campaign), + name: campaign.Opportunity?.name, + opportunityIdentifier: campaign.Opportunity?.identifier, + opportunityAction: campaign.Opportunity?.action, +}); + +export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null { const baseFields = getBaseCampaignFields(campaign); - // For SINGLETOKEN campaigns, use targetToken as the identifier - const marketId = - campaign.type === 'MORPHOSUPPLY_SINGLETOKEN' - ? `singletoken_${campaign.params.targetToken}_${campaign.computeChainId}` - : campaign.params.market; - - // Add type-specific fields if (campaign.type === 'MORPHOSUPPLY_SINGLETOKEN') { + const targetToken = campaign.params.targetToken; + if (!targetToken) return null; + return { ...baseFields, - marketId, + marketId: `singletoken_${targetToken}_${campaign.computeChainId}`, targetToken: { - symbol: campaign.params.symbolTargetToken, - address: campaign.params.targetToken, + symbol: campaign.params.symbolTargetToken ?? '', + address: targetToken, }, }; } + const marketId = campaign.params.market; + if (!marketId) return null; + return { ...baseFields, marketId, - collateralToken: { symbol: campaign.params.symbolCollateralToken }, - loanToken: { symbol: campaign.params.symbolLoanToken }, + collateralToken: campaign.params.symbolCollateralToken ? { symbol: campaign.params.symbolCollateralToken } : undefined, + loanToken: campaign.params.symbolLoanToken ? { symbol: campaign.params.symbolLoanToken } : undefined, }; } -// Expand MULTILENDBORROW campaigns into multiple SimplifiedCampaign objects (one per market) export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] { const baseFields = getBaseCampaignFields(campaign); - const markets = campaign.params.markets ?? []; - return markets.map((m) => ({ - ...baseFields, - marketId: m.campaignParameters.market, - collateralToken: { symbol: m.campaignParameters.symbolCollateralToken }, - loanToken: { symbol: m.campaignParameters.symbolLoanToken }, - })); + return (campaign.params.markets ?? []).flatMap((market) => { + const marketId = market.campaignParameters.market; + if (!marketId) return []; + + return [ + { + ...baseFields, + marketId, + collateralToken: { symbol: market.campaignParameters.symbolCollateralToken }, + loanToken: { symbol: market.campaignParameters.symbolLoanToken }, + }, + ]; + }); } diff --git a/src/utils/merklTypes.ts b/src/utils/merklTypes.ts index 58c806a88..3e7951944 100644 --- a/src/utils/merklTypes.ts +++ b/src/utils/merklTypes.ts @@ -1,5 +1,7 @@ export type MerklCampaignType = 'MORPHOSUPPLY' | 'MORPHOBORROW' | 'MORPHOSUPPLY_SINGLETOKEN' | 'MULTILENDBORROW'; +export type MarketRewardType = MerklCampaignType; + export type MerklCampaignStatus = { status: string; error: string; @@ -34,6 +36,21 @@ export type MerklToken = { symbol: string; }; +export type MerklOpportunity = { + id?: string; + identifier: string; + name: string; + chainId: number; + type: string; + status?: string; + action?: string; + apr?: number; + maxApr?: number; + liveCampaigns?: number; + tokens?: MerklToken[]; + campaigns?: MerklCampaign[]; +}; + export type MerklCreator = { address: string; tags: string[]; @@ -47,58 +64,42 @@ export type MerklMarketCampaignParams = { }; export type MerklCampaignParams = { - LLTV: string; - market: string; - duration: number; - blacklist: string[]; - loanToken: string; - whitelist: string[]; - forwarders: string[]; - targetToken: string; - collateralToken: string; - symbolLoanToken: string; - decimalsLoanToken: number; - symbolRewardToken: string; - symbolTargetToken: string; - decimalsRewardToken: number; - decimalsTargetToken: number; - symbolCollateralToken: string; - computeScoreParameters: { + LLTV?: string; + market?: string; + duration?: number; + blacklist?: string[]; + loanToken?: string; + whitelist?: string[]; + forwarders?: string[]; + targetToken?: string; + collateralToken?: string; + symbolLoanToken?: string; + decimalsLoanToken?: number; + symbolRewardToken?: string; + symbolTargetToken?: string; + decimalsRewardToken?: number; + decimalsTargetToken?: number; + symbolCollateralToken?: string; + computeScoreParameters?: { computeMethod: string; }; - decimalsCollateralToken: number; - distributionMethodParameters: { + decimalsCollateralToken?: number; + distributionMethodParameters?: { distributionMethod: string; distributionSettings: { - apr: string; - targetToken: string; - symbolTargetToken: string; - rewardTokenPricing: boolean; - targetTokenPricing: boolean; - decimalsTargetToken: number; + apr?: string; + targetToken?: string; + symbolTargetToken?: string; + rewardTokenPricing?: boolean; + targetTokenPricing?: boolean; + decimalsTargetToken?: number; }; }; - // For MULTILENDBORROW campaigns markets?: Array<{ campaignParameters: MerklMarketCampaignParams; }>; }; -export type MerklOpportunity = { - id?: string; - identifier: string; - name: string; - chainId: number; - type: string; - status?: string; - action?: string; - apr?: number; - maxApr?: number; - liveCampaigns?: number; - tokens?: MerklToken[]; - campaigns?: MerklCampaign[]; -}; - export type MerklCampaign = { id: string; computeChainId: number; @@ -149,7 +150,7 @@ export type SimplifiedCampaign = { marketId: string; chainId: number; campaignId: string; - type: MerklCampaignType; + type: MarketRewardType; apr: number; rewardToken: { symbol: string; diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 3b97c971d..12da91803 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -50,9 +50,6 @@ const MORPHO_TOKEN_BASE = '0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842'; const MORPHO_TOKEN_MAINNET = '0x58D97B57BB95320F9a05dC918Aef65434969c2B2'; const MORPHO_LEGACY = '0x9994E35Db50125E0DF82e4c2dde62496CE330999'; -// wrapper to convert legacy morpho tokens -const MORPHO_TOKEN_WRAPPER = '0x9d03bb2092270648d7480049d0e58d2fcf0e5123'; - const supportedTokens = [ { symbol: 'USDC', @@ -1074,6 +1071,5 @@ export { MORPHO_TOKEN_BASE, MORPHO_TOKEN_MAINNET, MORPHO_LEGACY, - MORPHO_TOKEN_WRAPPER, blacklistTokens, }; From 6df0eb2612d72468fe9138a5334146f88d6ffdb2 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 12 Jun 2026 03:28:50 +0800 Subject: [PATCH 2/3] Use Merkl for vault rewards --- docs/TECHNICAL_OVERVIEW.md | 10 +- docs/VALIDATIONS.md | 4 +- src/data-sources/merkl/vault-rewards.ts | 106 ++++++++++++++++++ src/data-sources/morpho-api/vaults.ts | 52 +-------- .../components/campaign-badge.tsx | 10 +- .../components/campaign-modal.tsx | 37 +++--- .../components/apy-breakdown-tooltip.tsx | 21 +++- .../markets/components/rewards-indicator.tsx | 4 +- src/graphql/vault-queries.ts | 19 ---- src/hooks/queries/useMerklCampaignsQuery.ts | 9 +- src/hooks/queries/useUserRewardsQuery.ts | 11 +- src/hooks/queries/useVaultV2RewardsQuery.ts | 6 +- src/hooks/useMarketCampaigns.ts | 10 +- src/utils/merklApi.ts | 15 ++- src/utils/merklTypes.ts | 10 +- 15 files changed, 200 insertions(+), 124 deletions(-) create mode 100644 src/data-sources/merkl/vault-rewards.ts diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 4d2b64f8e..694115126 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -212,13 +212,13 @@ 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` | +| 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` | @@ -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 | @@ -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` | Market reward campaigns, configured HOLD opportunity lookups, 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 | diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md index a29a182ce..660551c78 100644 --- a/docs/VALIDATIONS.md +++ b/docs/VALIDATIONS.md @@ -81,8 +81,8 @@ Use this file at the end of non-trivial work. Do not front-load it at task start - 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 reward APR display may use Morpho API vault reward fields (`vaultV2ByAddress.rewards` or documented successors) when the feature is scoped to Vault V2 rewards. -- User reward claiming must use direct Merkl API claim/proof data; deprecated Morpho URD/non-Merkl reward claim paths should stay out of the app. +- 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. diff --git a/src/data-sources/merkl/vault-rewards.ts b/src/data-sources/merkl/vault-rewards.ts new file mode 100644 index 000000000..e1555d248 --- /dev/null +++ b/src/data-sources/merkl/vault-rewards.ts @@ -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(); + + 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 => { + try { + const { data, error, status } = await fetchMerklApi('/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; + } +}; diff --git a/src/data-sources/morpho-api/vaults.ts b/src/data-sources/morpho-api/vaults.ts index 10840eeb7..1fa20940e 100644 --- a/src/data-sources/morpho-api/vaults.ts +++ b/src/data-sources/morpho-api/vaults.ts @@ -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 = { @@ -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; @@ -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) => { @@ -316,32 +292,6 @@ export const fetchListedMorphoVaultV2Metadata = async (): Promise => { - if (!supportsMorphoApiChainId(chainId)) { - return null; - } - - try { - const response = await morphoGraphqlFetcher(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> => { if (vaults.length === 0) { return new Map(); diff --git a/src/features/market-detail/components/campaign-badge.tsx b/src/features/market-detail/components/campaign-badge.tsx index 8c1a8adff..230a93b1c 100644 --- a/src/features/market-detail/components/campaign-badge.tsx +++ b/src/features/market-detail/components/campaign-badge.tsx @@ -14,6 +14,11 @@ type CampaignBadgeProps = { filterType?: 'supply' | 'borrow'; }; +// Supply campaign types: MORPHOSUPPLY, MORPHOSUPPLY_SINGLETOKEN, MULTILENDBORROW +const SUPPLY_CAMPAIGN_TYPES = ['MORPHOSUPPLY', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; +// Borrow campaign types: MORPHOBORROW +const BORROW_CAMPAIGN_TYPES = ['MORPHOBORROW']; + export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted, filterType }: CampaignBadgeProps) { const [isModalOpen, setIsModalOpen] = useState(false); @@ -28,9 +33,8 @@ export function CampaignBadge({ marketId, loanTokenAddress, chainId, whitelisted const filteredCampaigns = useMemo(() => { if (!filterType) return activeCampaigns; - return activeCampaigns.filter((campaign) => - filterType === 'borrow' ? campaign.type === 'MORPHOBORROW' : campaign.type !== 'MORPHOBORROW', - ); + const allowedTypes = filterType === 'supply' ? SUPPLY_CAMPAIGN_TYPES : BORROW_CAMPAIGN_TYPES; + return activeCampaigns.filter((campaign) => allowedTypes.includes(campaign.type)); }, [activeCampaigns, filterType]); if (loading || !hasActiveRewards || filteredCampaigns.length === 0) { diff --git a/src/features/market-detail/components/campaign-modal.tsx b/src/features/market-detail/components/campaign-modal.tsx index 4595242bf..64241c1b1 100644 --- a/src/features/market-detail/components/campaign-modal.tsx +++ b/src/features/market-detail/components/campaign-modal.tsx @@ -1,18 +1,18 @@ 'use client'; import { ExternalLinkIcon } from '@radix-ui/react-icons'; +import Image from 'next/image'; import Link from 'next/link'; -import { TokenIcon } from '@/components/shared/token-icon'; import { Button } from '@/components/ui/button'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { getMerklCampaignURL } from '@/utils/external'; -import type { SimplifiedCampaign, MarketRewardType } from '@/utils/merklTypes'; +import type { SimplifiedCampaign, MerklCampaignType } from '@/utils/merklTypes'; -const CAMPAIGN_TYPE_CONFIG: Record = { +const CAMPAIGN_TYPE_CONFIG: Record = { MORPHOSUPPLY: { badge: 'Lender Rewards' }, - MORPHOBORROW: { badge: 'Borrow Rewards' }, MORPHOSUPPLY_SINGLETOKEN: { badge: 'Lender Rewards' }, - MULTILENDBORROW: { badge: 'Market Rewards' }, + MULTILENDBORROW: { badge: 'Lend/Borrow Rewards' }, + MORPHOBORROW: { badge: 'Borrow Rewards' }, }; type CampaignModalProps = { @@ -22,15 +22,17 @@ type CampaignModalProps = { }; function getUrlIdentifier(campaign: SimplifiedCampaign): string { + // Always prefer opportunityIdentifier from the Opportunity object if (campaign.opportunityIdentifier) { return campaign.opportunityIdentifier; } - - if (campaign.type === 'MORPHOSUPPLY_SINGLETOKEN') { - return campaign.targetToken?.address ?? campaign.campaignId; + // Fallback for legacy data + switch (campaign.type) { + case 'MORPHOSUPPLY_SINGLETOKEN': + return campaign.targetToken?.address ?? campaign.campaignId; + default: + return campaign.marketId.slice(0, 42); } - - return campaign.marketId.slice(0, 42); } function formatCampaignDate(timestamp: number): string { @@ -42,7 +44,8 @@ function formatCampaignDate(timestamp: number): string { } function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) { - const merklUrl = getMerklCampaignURL(campaign.chainId, campaign.type, getUrlIdentifier(campaign)); + const urlIdentifier = getUrlIdentifier(campaign); + const merklUrl = getMerklCampaignURL(campaign.chainId, campaign.type, urlIdentifier); const { badge } = CAMPAIGN_TYPE_CONFIG[campaign.type] ?? CAMPAIGN_TYPE_CONFIG.MORPHOSUPPLY; @@ -53,12 +56,12 @@ function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) {
{badge}
- {campaign.rewardToken.symbol}
@@ -72,7 +75,7 @@ function CampaignRow({ campaign }: { campaign: SimplifiedCampaign }) { {/* Date Range + Link */}
- {formatCampaignDate(campaign.startTimestamp)} - {formatCampaignDate(campaign.endTimestamp)} + {formatCampaignDate(campaign.startTimestamp)} — {formatCampaignDate(campaign.endTimestamp)} } onClose={onClose} /> diff --git a/src/features/markets/components/apy-breakdown-tooltip.tsx b/src/features/markets/components/apy-breakdown-tooltip.tsx index 1d6abeef1..576d49ac8 100644 --- a/src/features/markets/components/apy-breakdown-tooltip.tsx +++ b/src/features/markets/components/apy-breakdown-tooltip.tsx @@ -24,8 +24,27 @@ type APYCellProps = { mode?: RateMode; }; +const modeByType: Record = { + MORPHOSUPPLY: 'supply', + MORPHOSUPPLY_SINGLETOKEN: 'supply', + MORPHOBORROW: 'borrow', +}; + const getCampaignMode = (campaign: SimplifiedCampaign): RateMode | null => { - return campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' ? 'borrow' : 'supply'; + const directMode = modeByType[campaign.type]; + if (directMode) return directMode; + + if (campaign.type !== 'MULTILENDBORROW') return null; + + const action = campaign.opportunityAction?.toUpperCase(); + if (action === 'LEND') return 'supply'; + if (action === 'BORROW') return 'borrow'; + + const name = campaign.name?.toLowerCase() ?? ''; + if (name.includes('borrow')) return 'borrow'; + if (name.includes('supply') || name.includes('lend')) return 'supply'; + + return null; }; const filterCampaignsByMode = (campaigns: SimplifiedCampaign[], mode: RateMode): SimplifiedCampaign[] => { diff --git a/src/features/markets/components/rewards-indicator.tsx b/src/features/markets/components/rewards-indicator.tsx index 8030c0473..c2674195b 100644 --- a/src/features/markets/components/rewards-indicator.tsx +++ b/src/features/markets/components/rewards-indicator.tsx @@ -9,8 +9,8 @@ type RewardsIndicatorProps = { size: number; chainId: number; marketId: string; - loanTokenAddress: string; - whitelisted: boolean; + loanTokenAddress?: string; + whitelisted: boolean; // whitelisted by morpho }; export function RewardsIndicator({ marketId, chainId, loanTokenAddress, whitelisted, size }: RewardsIndicatorProps) { diff --git a/src/graphql/vault-queries.ts b/src/graphql/vault-queries.ts index a5351f94f..9d607124e 100644 --- a/src/graphql/vault-queries.ts +++ b/src/graphql/vault-queries.ts @@ -83,22 +83,3 @@ export const vaultV2MetadataQuery = ` } } `; - -export const vaultV2RewardsQuery = ` - query VaultV2Rewards($address: String!, $chainId: Int!) { - vaultV2ByAddress(address: $address, chainId: $chainId) { - address - apy - rewards { - supplyApr - asset { - address - symbol - price { - usd - } - } - } - } - } -`; diff --git a/src/hooks/queries/useMerklCampaignsQuery.ts b/src/hooks/queries/useMerklCampaignsQuery.ts index b952b0213..6b9880b4b 100644 --- a/src/hooks/queries/useMerklCampaignsQuery.ts +++ b/src/hooks/queries/useMerklCampaignsQuery.ts @@ -1,14 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import { useDeferredQueryEnable } from '@/hooks/useDeferredQueryEnable'; import { expandMultiLendBorrowCampaign, fetchActiveCampaigns, simplifyMerklCampaign } from '@/utils/merklApi'; -import type { MerklCampaignType, SimplifiedCampaign } from '@/utils/merklTypes'; +import type { MarketRewardType, SimplifiedCampaign } from '@/utils/merklTypes'; -const CAMPAIGN_TYPES_TO_FETCH: MerklCampaignType[] = ['MORPHOSUPPLY', 'MORPHOBORROW', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; +const CAMPAIGN_TYPES_TO_FETCH: MarketRewardType[] = ['MORPHOSUPPLY', 'MORPHOBORROW', 'MORPHOSUPPLY_SINGLETOKEN', 'MULTILENDBORROW']; -const toSimplifiedCampaigns = ( - type: MerklCampaignType, - campaigns: Awaited>, -): SimplifiedCampaign[] => +const toSimplifiedCampaigns = (type: MarketRewardType, campaigns: Awaited>): SimplifiedCampaign[] => campaigns.flatMap((campaign) => { const typedCampaign = { ...campaign, diff --git a/src/hooks/queries/useUserRewardsQuery.ts b/src/hooks/queries/useUserRewardsQuery.ts index 73188ea71..63d8b2a77 100644 --- a/src/hooks/queries/useUserRewardsQuery.ts +++ b/src/hooks/queries/useUserRewardsQuery.ts @@ -52,13 +52,8 @@ async function fetchMerklRewards( const rewardsList: RewardResponseType[] = []; const rewardsWithProofsList: MerklRewardWithProofs[] = []; - const { data, error, status } = await fetchMerklApi(`/v4/users/${userAddress}/rewards`, { - chainId: ALL_SUPPORTED_NETWORKS, + const { data, error, status } = await fetchMerklApi(`/v4/users/${userAddress}/rewards/summary`, { reloadChainId: options.forceReload ? ALL_SUPPORTED_NETWORKS : undefined, - test: false, - claimableOnly: true, - breakdownPage: 0, - type: 'TOKEN', }); if (error || status !== 200) { @@ -70,6 +65,10 @@ async function fetchMerklRewards( } for (const chainData of data) { + if (!ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id)) { + continue; + } + if (!chainData.rewards || chainData.rewards.length === 0) { continue; } diff --git a/src/hooks/queries/useVaultV2RewardsQuery.ts b/src/hooks/queries/useVaultV2RewardsQuery.ts index ec1947a2b..5ab1ab886 100644 --- a/src/hooks/queries/useVaultV2RewardsQuery.ts +++ b/src/hooks/queries/useVaultV2RewardsQuery.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import type { Address } from 'viem'; -import { fetchMorphoVaultV2Rewards, type MorphoVaultV2Rewards } from '@/data-sources/morpho-api/vaults'; +import { fetchMerklVaultV2Rewards, type MerklVaultV2Rewards } from '@/data-sources/merkl/vault-rewards'; import type { SupportedNetworks } from '@/utils/networks'; type UseVaultV2RewardsQueryArgs = { @@ -11,9 +11,9 @@ type UseVaultV2RewardsQueryArgs = { export function useVaultV2RewardsQuery({ vaultAddress, chainId }: UseVaultV2RewardsQueryArgs) { const normalizedVaultAddress = vaultAddress?.toLowerCase() as Address | undefined; - return useQuery({ + return useQuery({ queryKey: ['vault-v2-rewards', normalizedVaultAddress, chainId], - queryFn: () => fetchMorphoVaultV2Rewards(normalizedVaultAddress!, chainId), + queryFn: () => fetchMerklVaultV2Rewards(normalizedVaultAddress!, chainId), enabled: Boolean(normalizedVaultAddress), staleTime: 5 * 60 * 1000, }); diff --git a/src/hooks/useMarketCampaigns.ts b/src/hooks/useMarketCampaigns.ts index d99b8ad3b..dd70bcf5a 100644 --- a/src/hooks/useMarketCampaigns.ts +++ b/src/hooks/useMarketCampaigns.ts @@ -2,13 +2,14 @@ import { useMemo } from 'react'; import type { SimplifiedCampaign } from '@/utils/merklTypes'; import { useMerklCampaignsQuery } from './queries/useMerklCampaignsQuery'; +// Blacklisted campaign IDs - these will be filtered out const BLACKLISTED_CAMPAIGN_IDS: string[] = [ // Seems to be reporting bad APY, not singleton for all market for sure // https://app.merkl.xyz/opportunities/base/MORPHOSUPPLY_SINGLETOKEN/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 '0x4b5aa0f66eb6a63e3b761de8fbbcc8154d568086c1234ba58516f3263a79200a', // https://app.merkl.xyz/opportunities/base/MORPHOSUPPLY_SINGLETOKEN/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913WHITELIST_PER_PROTOCOL '0x97380b45eed593b3108275de15ba89e452eaffeb04f0ffc7d8f131cf8c70f7a3', - '0x515f512312edec4254029e0696fc0df3862dcbf1a18e9e8e345f7c52ec528b0a', + '0x515f512312edec4254029e0696fc0df3862dcbf1a18e9e8e345f7c52ec528b0a', // mainnet ]; type UseMarketCampaignsReturn = { @@ -34,10 +35,13 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa const result = useMemo(() => { const normalizedMarketId = marketId.toLowerCase(); + // Filter campaigns for this specific market const directMarketCampaigns = allCampaigns.filter( (campaign) => campaign.chainId === chainId && campaign.marketId?.toLowerCase() === normalizedMarketId, ); + // For SINGLETOKEN campaigns, also include campaigns where the loan token matches the target token + // the market has to be whitelisted const singleTokenCampaigns = loanTokenAddress && chainId && whitelisted ? allCampaigns.filter( @@ -48,9 +52,13 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa ) : []; + // Combine both types of campaigns and filter out blacklisted ones const allMarketCampaigns = [...directMarketCampaigns, ...singleTokenCampaigns].filter((campaign) => { if (BLACKLISTED_CAMPAIGN_IDS.includes(campaign.campaignId.toLowerCase())) return false; + + // temp: remove all Morpho Vault V2 campaigns until we find better way to filter if (campaign.name?.includes('Morpho Vault V2')) return false; + return true; }); diff --git a/src/utils/merklApi.ts b/src/utils/merklApi.ts index a19660f58..165a7c54e 100644 --- a/src/utils/merklApi.ts +++ b/src/utils/merklApi.ts @@ -1,4 +1,11 @@ -import type { MerklApiParams, MerklCampaign, MerklOpportunityLookupParams, MerklOpportunity, SimplifiedCampaign } from './merklTypes'; +import type { + MarketRewardType, + MerklApiParams, + MerklCampaign, + MerklOpportunityLookupParams, + MerklOpportunity, + SimplifiedCampaign, +} from './merklTypes'; const MERKL_API_PROXY_BASE_PATH = '/api/merkl'; @@ -160,7 +167,7 @@ const isCampaignActive = (campaign: MerklCampaign): boolean => { }; const getBaseCampaignFields = ( - campaign: MerklCampaign, + campaign: MerklCampaign & { type: MarketRewardType }, ): Pick< SimplifiedCampaign, | 'chainId' @@ -192,7 +199,7 @@ const getBaseCampaignFields = ( opportunityAction: campaign.Opportunity?.action, }); -export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null { +export function simplifyMerklCampaign(campaign: MerklCampaign & { type: MarketRewardType }): SimplifiedCampaign | null { const baseFields = getBaseCampaignFields(campaign); if (campaign.type === 'MORPHOSUPPLY_SINGLETOKEN') { @@ -220,7 +227,7 @@ export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampai }; } -export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] { +export function expandMultiLendBorrowCampaign(campaign: MerklCampaign & { type: MarketRewardType }): SimplifiedCampaign[] { const baseFields = getBaseCampaignFields(campaign); return (campaign.params.markets ?? []).flatMap((market) => { diff --git a/src/utils/merklTypes.ts b/src/utils/merklTypes.ts index 3e7951944..5fac8cf32 100644 --- a/src/utils/merklTypes.ts +++ b/src/utils/merklTypes.ts @@ -1,6 +1,8 @@ -export type MerklCampaignType = 'MORPHOSUPPLY' | 'MORPHOBORROW' | 'MORPHOSUPPLY_SINGLETOKEN' | 'MULTILENDBORROW'; +export type MarketRewardType = 'MORPHOSUPPLY' | 'MORPHOBORROW' | 'MORPHOSUPPLY_SINGLETOKEN' | 'MULTILENDBORROW'; -export type MarketRewardType = MerklCampaignType; +export type MerklCampaignType = MarketRewardType; + +export type MerklRawCampaignType = MarketRewardType | 'ERC20LOGPROCESSOR' | 'MORPHOVAULT'; export type MerklCampaignStatus = { status: string; @@ -105,7 +107,7 @@ export type MerklCampaign = { computeChainId: number; distributionChainId: number; campaignId: string; - type: MerklCampaignType; + type: MerklRawCampaignType; distributionType: string; subType: number; rewardTokenId: string; @@ -131,7 +133,7 @@ export type MerklCampaign = { export type MerklCampaignsResponse = MerklCampaign[]; export type MerklApiParams = { - type?: MerklCampaignType; + type?: MarketRewardType; chainId?: number; items?: number; page?: number; From dcca9afc0f8bcb420ec955d228610c88601d6959 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Fri, 12 Jun 2026 11:03:49 +0800 Subject: [PATCH 3/3] Address Merkl reward review feedback --- .../markets/components/market-details-block.tsx | 8 +++++--- .../markets/components/rewards-indicator.tsx | 3 ++- src/hooks/queries/useUserRewardsQuery.ts | 4 ++-- src/hooks/useMarketCampaigns.ts | 4 ++-- src/utils/merklApi.ts | 14 ++++++++++++-- src/utils/merklTypes.ts | 4 ++-- 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/features/markets/components/market-details-block.tsx b/src/features/markets/components/market-details-block.tsx index fac88c571..94e21d55a 100644 --- a/src/features/markets/components/market-details-block.tsx +++ b/src/features/markets/components/market-details-block.tsx @@ -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'; @@ -52,9 +53,10 @@ export function MarketDetailsBlock({ }); const modeCampaigns = useMemo( () => - activeCampaigns.filter((campaign) => - mode === 'borrow' ? campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' : campaign.type !== 'MORPHOBORROW', - ), + activeCampaigns.filter((campaign) => { + const isBorrow = isBorrowCampaign(campaign); + return mode === 'borrow' ? isBorrow : !isBorrow; + }), [activeCampaigns, mode], ); const hasModeRewards = hasActiveRewards && modeCampaigns.length > 0; diff --git a/src/features/markets/components/rewards-indicator.tsx b/src/features/markets/components/rewards-indicator.tsx index c2674195b..7adf55dbd 100644 --- a/src/features/markets/components/rewards-indicator.tsx +++ b/src/features/markets/components/rewards-indicator.tsx @@ -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 = { @@ -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' || campaign.opportunityAction === 'BORROW' ? 'borrower' : 'supplier'; + const rewardType = isBorrowCampaign(campaign) ? 'borrower' : 'supplier'; return `${campaign.rewardToken.symbol} ${rewardType} reward +${campaign.apr.toFixed(2)}%`; }) .join('\n'); diff --git a/src/hooks/queries/useUserRewardsQuery.ts b/src/hooks/queries/useUserRewardsQuery.ts index 63d8b2a77..b12b8970d 100644 --- a/src/hooks/queries/useUserRewardsQuery.ts +++ b/src/hooks/queries/useUserRewardsQuery.ts @@ -4,7 +4,7 @@ import type { Address } from 'viem'; import { fetchMerklApi } from '@/utils/merklApi'; import { reportHandledError } from '@/utils/sentry'; import type { RewardResponseType } from '@/utils/types'; -import { ALL_SUPPORTED_NETWORKS } from '@/utils/networks'; +import { ALL_SUPPORTED_NETWORKS, isSupportedNetwork } from '@/utils/networks'; export type MerklRewardWithProofs = { tokenAddress: Address; @@ -65,7 +65,7 @@ async function fetchMerklRewards( } for (const chainData of data) { - if (!ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id)) { + if (!isSupportedNetwork(chainData.chain.id)) { continue; } diff --git a/src/hooks/useMarketCampaigns.ts b/src/hooks/useMarketCampaigns.ts index dd70bcf5a..02f4c8551 100644 --- a/src/hooks/useMarketCampaigns.ts +++ b/src/hooks/useMarketCampaigns.ts @@ -24,7 +24,7 @@ type UseMarketCampaignsReturn = { type MarketCampaignsOptions = { marketId: string; loanTokenAddress?: string; - chainId?: number; + chainId: number; whitelisted: boolean; }; @@ -37,7 +37,7 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa // Filter campaigns for this specific market const directMarketCampaigns = allCampaigns.filter( - (campaign) => campaign.chainId === chainId && campaign.marketId?.toLowerCase() === normalizedMarketId, + (campaign) => campaign.chainId === chainId && campaign.marketId.toLowerCase() === normalizedMarketId, ); // For SINGLETOKEN campaigns, also include campaigns where the loan token matches the target token diff --git a/src/utils/merklApi.ts b/src/utils/merklApi.ts index 165a7c54e..43dfd1855 100644 --- a/src/utils/merklApi.ts +++ b/src/utils/merklApi.ts @@ -11,6 +11,7 @@ const MERKL_API_PROXY_BASE_PATH = '/api/merkl'; const MERKL_LIVE_STATUS = 'LIVE'; const MERKL_HOLD_ACTION = 'HOLD'; +const DEFAULT_CAMPAIGN_PAGE_SIZE = 100; type MerklQueryValue = string | number | boolean | readonly (string | number | boolean)[]; @@ -88,7 +89,8 @@ export async function fetchCampaigns(params: MerklApiParams = {}): Promise = {}): Promise { const now = Math.floor(Date.now() / 1000); - const pageSize = params.items ?? 100; + const requestedPageSize = params.items ?? DEFAULT_CAMPAIGN_PAGE_SIZE; + const pageSize = Number.isInteger(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : DEFAULT_CAMPAIGN_PAGE_SIZE; const allCampaigns: MerklCampaign[] = []; let currentPage = 0; @@ -161,6 +163,9 @@ export const getMerklOpportunityAprDecimal = (opportunity: MerklOpportunity | nu return aprPercent / 100; }; +export const isBorrowCampaign = (campaign: Pick): boolean => + campaign.type === 'MORPHOBORROW' || campaign.opportunityAction?.toUpperCase() === 'BORROW'; + const isCampaignActive = (campaign: MerklCampaign): boolean => { const now = Math.floor(Date.now() / 1000); return campaign.startTimestamp <= now && campaign.endTimestamp > now && Number.isFinite(campaign.apr) && campaign.apr > 0; @@ -200,6 +205,8 @@ const getBaseCampaignFields = ( }); export function simplifyMerklCampaign(campaign: MerklCampaign & { type: MarketRewardType }): SimplifiedCampaign | null { + if (!campaign.params) return null; + const baseFields = getBaseCampaignFields(campaign); if (campaign.type === 'MORPHOSUPPLY_SINGLETOKEN') { @@ -228,9 +235,12 @@ export function simplifyMerklCampaign(campaign: MerklCampaign & { type: MarketRe } export function expandMultiLendBorrowCampaign(campaign: MerklCampaign & { type: MarketRewardType }): SimplifiedCampaign[] { + const markets = campaign.params?.markets; + if (!markets) return []; + const baseFields = getBaseCampaignFields(campaign); - return (campaign.params.markets ?? []).flatMap((market) => { + return markets.flatMap((market) => { const marketId = market.campaignParameters.market; if (!marketId) return []; diff --git a/src/utils/merklTypes.ts b/src/utils/merklTypes.ts index 5fac8cf32..0fe2a010c 100644 --- a/src/utils/merklTypes.ts +++ b/src/utils/merklTypes.ts @@ -2,7 +2,7 @@ export type MarketRewardType = 'MORPHOSUPPLY' | 'MORPHOBORROW' | 'MORPHOSUPPLY_S export type MerklCampaignType = MarketRewardType; -export type MerklRawCampaignType = MarketRewardType | 'ERC20LOGPROCESSOR' | 'MORPHOVAULT'; +export type MerklRawCampaignType = MarketRewardType | 'ERC20LOGPROCESSOR' | 'MORPHOCOLLATERAL' | 'MORPHOVAULT'; export type MerklCampaignStatus = { status: string; @@ -118,7 +118,7 @@ export type MerklCampaign = { dailyRewards: number; apr: number; creatorAddress: string; - params: MerklCampaignParams; + params?: MerklCampaignParams; chain: MerklChain; rewardToken: MerklToken; distributionChain: MerklChain;