diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md
index eed2c78ab..694115126 100644
--- a/docs/TECHNICAL_OVERVIEW.md
+++ b/docs/TECHNICAL_OVERVIEW.md
@@ -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` |
@@ -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 |
@@ -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
@@ -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 |
diff --git a/docs/VALIDATIONS.md b/docs/VALIDATIONS.md
index 284e23282..660551c78 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 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/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/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
)}
- {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 = isBorrowCampaign(campaign) ? '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/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 056f9d29e..6b9880b4b 100644
--- a/src/hooks/queries/useMerklCampaignsQuery.ts
+++ b/src/hooks/queries/useMerklCampaignsQuery.ts
@@ -1,9 +1,24 @@
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 { 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: MarketRewardType, 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);
@@ -12,29 +27,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/queries/useUserRewardsQuery.ts b/src/hooks/queries/useUserRewardsQuery.ts
index 73188ea71..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;
@@ -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 (!isSupportedNetwork(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 6d5e6a423..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;
};
@@ -36,7 +36,9 @@ export function useMarketCampaigns(options: MarketCampaignsOptions): UseMarketCa
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
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..43dfd1855 100644
--- a/src/utils/merklApi.ts
+++ b/src/utils/merklApi.ts
@@ -1,9 +1,17 @@
-import type { MerklCampaign, SimplifiedCampaign, MerklApiParams, MerklOpportunityLookupParams, MerklOpportunity } from './merklTypes';
+import type {
+ MarketRewardType,
+ MerklApiParams,
+ MerklCampaign,
+ MerklOpportunityLookupParams,
+ MerklOpportunity,
+ SimplifiedCampaign,
+} from './merklTypes';
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)[];
@@ -56,44 +64,37 @@ 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 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;
- let hasMore = true;
- while (hasMore) {
+ while (true) {
const batch = await fetchCampaigns({
...params,
items: pageSize,
@@ -104,12 +105,11 @@ export async function fetchActiveCampaigns(params: Omit): 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;
-}
+ return campaign.startTimestamp <= now && campaign.endTimestamp > now && Number.isFinite(campaign.apr) && campaign.apr > 0;
+};
-// Helper to extract common campaign fields
-function getBaseCampaignFields(
- campaign: MerklCampaign,
+const getBaseCampaignFields = (
+ campaign: MerklCampaign & { type: MarketRewardType },
): Pick<
SimplifiedCampaign,
| 'chainId'
@@ -185,65 +186,71 @@ 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,
- };
-}
+> => ({
+ 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 & { type: MarketRewardType }): SimplifiedCampaign | null {
+ if (!campaign.params) return null;
-// Adapter function to convert Merkl campaigns to SimplifiedCampaign.
-export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign {
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[] {
+export function expandMultiLendBorrowCampaign(campaign: MerklCampaign & { type: MarketRewardType }): SimplifiedCampaign[] {
+ const markets = campaign.params?.markets;
+ if (!markets) return [];
+
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 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..0fe2a010c 100644
--- a/src/utils/merklTypes.ts
+++ b/src/utils/merklTypes.ts
@@ -1,4 +1,8 @@
-export type MerklCampaignType = 'MORPHOSUPPLY' | 'MORPHOBORROW' | 'MORPHOSUPPLY_SINGLETOKEN' | 'MULTILENDBORROW';
+export type MarketRewardType = 'MORPHOSUPPLY' | 'MORPHOBORROW' | 'MORPHOSUPPLY_SINGLETOKEN' | 'MULTILENDBORROW';
+
+export type MerklCampaignType = MarketRewardType;
+
+export type MerklRawCampaignType = MarketRewardType | 'ERC20LOGPROCESSOR' | 'MORPHOCOLLATERAL' | 'MORPHOVAULT';
export type MerklCampaignStatus = {
status: string;
@@ -34,6 +38,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,64 +66,48 @@ 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;
distributionChainId: number;
campaignId: string;
- type: MerklCampaignType;
+ type: MerklRawCampaignType;
distributionType: string;
subType: number;
rewardTokenId: string;
@@ -115,7 +118,7 @@ export type MerklCampaign = {
dailyRewards: number;
apr: number;
creatorAddress: string;
- params: MerklCampaignParams;
+ params?: MerklCampaignParams;
chain: MerklChain;
rewardToken: MerklToken;
distributionChain: MerklChain;
@@ -130,7 +133,7 @@ export type MerklCampaign = {
export type MerklCampaignsResponse = MerklCampaign[];
export type MerklApiParams = {
- type?: MerklCampaignType;
+ type?: MarketRewardType;
chainId?: number;
items?: number;
page?: number;
@@ -149,7 +152,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,
};