Use Merkl for market and vault rewards#575
Conversation
📝 WalkthroughWalkthroughThis PR consolidates reward sourcing around Merkl by adding a new Merkl vault V2 rewards data source, refactoring the campaign validation pipeline, removing legacy MORPHO wrapping, and filtering market campaigns by supply/borrow mode and chain. ChangesMerkl Reward Source Consolidation & Legacy MORPHO Removal
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Code Review
This pull request removes legacy Morpho wrapping logic, updates technical documentation, and refactors Merkl campaign querying and filtering. Feedback on the changes highlights a classification bug in campaign-badge.tsx and market-details-block.tsx where MULTILENDBORROW campaigns with a borrow action are incorrectly grouped under supply rewards. Additionally, defensive checks are recommended in merklApi.ts to prevent potential runtime crashes when accessing campaign.params.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| return activeCampaigns.filter((campaign) => | ||
| filterType === 'borrow' ? campaign.type === 'MORPHOBORROW' : campaign.type !== 'MORPHOBORROW', | ||
| ); |
There was a problem hiding this comment.
There is an inconsistency in how borrow/supply campaigns are filtered. If filterType === 'borrow', it only checks campaign.type === 'MORPHOBORROW'. This misses MULTILENDBORROW campaigns that are actually borrow campaigns (where campaign.opportunityAction === 'BORROW'). Conversely, if filterType === 'supply', it incorrectly includes those borrow-action MULTILENDBORROW campaigns because their type is not MORPHOBORROW.
We should use a consistent check that accounts for both the campaign type and the opportunity action.
return activeCampaigns.filter((campaign) => {
const isBorrow = campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW';
return filterType === 'borrow' ? isBorrow : !isBorrow;
});
| activeCampaigns.filter((campaign) => | ||
| mode === 'borrow' ? campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' : campaign.type !== 'MORPHOBORROW', | ||
| ), |
There was a problem hiding this comment.
This filter has the same classification bug as the campaign badge. When mode === 'supply', it uses campaign.type !== 'MORPHOBORROW', which incorrectly includes MULTILENDBORROW campaigns that have opportunityAction === 'BORROW'. This causes those campaigns to be counted as both supply and borrow rewards.
We should use a consistent check that accounts for both the campaign type and the opportunity action.
| activeCampaigns.filter((campaign) => | |
| mode === 'borrow' ? campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW' : campaign.type !== 'MORPHOBORROW', | |
| ), | |
| activeCampaigns.filter((campaign) => { | |
| const isBorrow = campaign.type === 'MORPHOBORROW' || campaign.opportunityAction === 'BORROW'; | |
| return mode === 'borrow' ? isBorrow : !isBorrow; | |
| }), |
| export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null { | ||
| const baseFields = getBaseCampaignFields(campaign); |
There was a problem hiding this comment.
Since campaign.params comes from an external API and its properties are marked as optional in MerklCampaignParams, we should defensively check if campaign.params itself is defined before accessing its properties to prevent potential runtime crashes (e.g., TypeError: Cannot read properties of undefined).
| export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null { | |
| const baseFields = getBaseCampaignFields(campaign); | |
| export function simplifyMerklCampaign(campaign: MerklCampaign): SimplifiedCampaign | null { | |
| if (!campaign.params) return null; | |
| const baseFields = getBaseCampaignFields(campaign); |
| export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] { | ||
| const baseFields = getBaseCampaignFields(campaign); |
There was a problem hiding this comment.
Similarly, we should defensively check if campaign.params is defined before accessing campaign.params.markets to prevent runtime crashes if the external API returns a response without params.
| export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] { | |
| const baseFields = getBaseCampaignFields(campaign); | |
| export function expandMultiLendBorrowCampaign(campaign: MerklCampaign): SimplifiedCampaign[] { | |
| if (!campaign.params?.markets) return []; | |
| const baseFields = getBaseCampaignFields(campaign); |
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/utils/merklApi.ts (1)
84-104:⚠️ Potential issue | 🟠 Major | ⚡ Quick winValidate page size before entering the infinite loop.
Line 84 accepts
params.itemsas-is. If it is0or negative, the break check at Line 99 can never succeed, so the loop at Line 88 can run forever.Suggested fix
export async function fetchActiveCampaigns(params: Omit<MerklApiParams, 'startTimestamp' | 'endTimestamp'> = {}): Promise<MerklCampaign[]> { const now = Math.floor(Date.now() / 1000); - const pageSize = params.items ?? 100; + const requestedPageSize = params.items ?? 100; + const pageSize = Number.isInteger(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : 100; const allCampaigns: MerklCampaign[] = []; let currentPage = 0;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/utils/merklApi.ts` around lines 84 - 104, The loop can become infinite if params.items is 0 or negative; validate and normalize pageSize before entering the while loop by deriving pageSize from params.items and ensuring it's a positive integer (e.g., default to 100 or clamp to a minimum of 1) so that the batch.length < pageSize break condition can eventually succeed; update the logic around the pageSize assignment (used with fetchCampaigns, currentPage, and allCampaigns) to either throw a clear validation error for non-positive values or set a safe default/minimum.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/features/markets/components/market-details-block.tsx`:
- Around line 53-57: Replace the inline borrow-detection logic inside the
modeCampaigns useMemo with a shared helper: create and export an
isBorrowCampaign(campaign) utility that returns true when campaign.type ===
'MORPHOBORROW' || campaign.opportunityAction === 'BORROW', import that helper
into this component, and update the useMemo filter to use
isBorrowCampaign(campaign) for the borrow branch (and its negation for the
non-borrow branch) so modeCampaigns uses the centralized logic.
---
Outside diff comments:
In `@src/utils/merklApi.ts`:
- Around line 84-104: The loop can become infinite if params.items is 0 or
negative; validate and normalize pageSize before entering the while loop by
deriving pageSize from params.items and ensuring it's a positive integer (e.g.,
default to 100 or clamp to a minimum of 1) so that the batch.length < pageSize
break condition can eventually succeed; update the logic around the pageSize
assignment (used with fetchCampaigns, currentPage, and allCampaigns) to either
throw a clear validation error for non-positive values or set a safe
default/minimum.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 320d5eca-db5f-4b78-8dca-39014cf82f55
📒 Files selected for processing (15)
docs/TECHNICAL_OVERVIEW.mddocs/VALIDATIONS.mdsrc/abis/morpho-wrapper.tssrc/features/market-detail/components/campaign-badge.tsxsrc/features/market-detail/components/campaign-modal.tsxsrc/features/markets/components/apy-breakdown-tooltip.tsxsrc/features/markets/components/market-details-block.tsxsrc/features/markets/components/rewards-indicator.tsxsrc/features/rewards/rewards-view.tsxsrc/hooks/queries/useMerklCampaignsQuery.tssrc/hooks/useMarketCampaigns.tssrc/hooks/useWrapLegacyMorpho.tssrc/utils/merklApi.tssrc/utils/merklTypes.tssrc/utils/tokens.ts
💤 Files with no reviewable changes (3)
- src/hooks/useWrapLegacyMorpho.ts
- src/abis/morpho-wrapper.ts
- src/utils/tokens.ts
d249c55 to
6df0eb2
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/hooks/useMarketCampaigns.ts (1)
40-40: ⚡ Quick winUnnecessary optional chaining on
marketId.
campaign.marketIdis non-optional inSimplifiedCampaign, so.toLowerCase()can be called directly.♻️ Remove optional chaining
- (campaign) => campaign.chainId === chainId && campaign.marketId?.toLowerCase() === normalizedMarketId, + (campaign) => campaign.chainId === chainId && campaign.marketId.toLowerCase() === normalizedMarketId,🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/hooks/useMarketCampaigns.ts` at line 40, The predicate in useMarketCampaigns.ts unnecessarily uses optional chaining on campaign.marketId even though SimplifiedCampaign defines marketId as non-optional; update the filter predicate (the arrow function comparing chainId and marketId) to call toLowerCase() directly on campaign.marketId (i.e., remove the `?`) so it reads campaign.chainId === chainId && campaign.marketId.toLowerCase() === normalizedMarketId, leaving the rest of the logic unchanged.src/hooks/queries/useUserRewardsQuery.ts (1)
68-70: ⚡ Quick winUse existing
isSupportedNetworktype guard.The project provides
isSupportedNetwork(chainId)for chain ID validation. Using it here improves type safety and follows the established pattern.♻️ Proposed refactor
- if (!ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id)) { + if (!isSupportedNetwork(chainData.chain.id)) { continue; }Add the import at the top:
-import { ALL_SUPPORTED_NETWORKS } from '`@/utils/networks`'; +import { ALL_SUPPORTED_NETWORKS, isSupportedNetwork } from '`@/utils/networks`';🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/hooks/queries/useUserRewardsQuery.ts` around lines 68 - 70, Replace the manual check using ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id) with the project type guard isSupportedNetwork to get proper type narrowing; add an import for isSupportedNetwork at the top of useUserRewardsQuery.ts and change the condition to if (!isSupportedNetwork(chainData.chain.id)) continue; so chainId is correctly type-guarded for downstream usage.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/hooks/useMarketCampaigns.ts`:
- Line 40: The filter in useMarketCampaigns that builds directMarketCampaigns
uses campaign.chainId === chainId which yields no results when chainId is
undefined; either make chainId required on MarketCampaignsOptions (update the
MarketCampaignsOptions type/interface to make chainId non-optional and adjust
callsites) or explicitly handle undefined by changing the predicate in the
directMarketCampaigns filter inside useMarketCampaigns to a safe check (e.g.,
only compare when chainId is defined or fall back to comparing only marketId
when chainId is undefined) and ensure normalizedMarketId logic remains intact.
---
Nitpick comments:
In `@src/hooks/queries/useUserRewardsQuery.ts`:
- Around line 68-70: Replace the manual check using
ALL_SUPPORTED_NETWORKS.includes(chainData.chain.id) with the project type guard
isSupportedNetwork to get proper type narrowing; add an import for
isSupportedNetwork at the top of useUserRewardsQuery.ts and change the condition
to if (!isSupportedNetwork(chainData.chain.id)) continue; so chainId is
correctly type-guarded for downstream usage.
In `@src/hooks/useMarketCampaigns.ts`:
- Line 40: The predicate in useMarketCampaigns.ts unnecessarily uses optional
chaining on campaign.marketId even though SimplifiedCampaign defines marketId as
non-optional; update the filter predicate (the arrow function comparing chainId
and marketId) to call toLowerCase() directly on campaign.marketId (i.e., remove
the `?`) so it reads campaign.chainId === chainId &&
campaign.marketId.toLowerCase() === normalizedMarketId, leaving the rest of the
logic unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 3d3f3400-187f-4a03-80cc-9a8f538eb15f
📒 Files selected for processing (12)
docs/TECHNICAL_OVERVIEW.mddocs/VALIDATIONS.mdsrc/data-sources/merkl/vault-rewards.tssrc/data-sources/morpho-api/vaults.tssrc/features/markets/components/rewards-indicator.tsxsrc/graphql/vault-queries.tssrc/hooks/queries/useMerklCampaignsQuery.tssrc/hooks/queries/useUserRewardsQuery.tssrc/hooks/queries/useVaultV2RewardsQuery.tssrc/hooks/useMarketCampaigns.tssrc/utils/merklApi.tssrc/utils/merklTypes.ts
💤 Files with no reviewable changes (1)
- src/graphql/vault-queries.ts
✅ Files skipped from review due to trivial changes (1)
- docs/TECHNICAL_OVERVIEW.md
🚧 Files skipped from review as they are similar to previous changes (2)
- src/hooks/queries/useMerklCampaignsQuery.ts
- src/utils/merklApi.ts
Summary
/v4/campaigns?withOpportunity=truethrough the existing/api/merklproxy/v4/opportunities?mainProtocolId=morpho&explorerAddress={vault}&campaigns=truelookups, removing the MorphovaultV2ByAddress.rewardsdependency/v4/users/{address}/rewards/summaryand keep claim/proof data behind the server-side/api/merklproxy withX-API-Key/rewards/:accountLive checks
MORPHOSUPPLY,MORPHOBORROW,MORPHOSUPPLY_SINGLETOKEN, andMULTILENDBORROWfor market campaign display; live Merkl opportunities also include vault/collateral types such asERC20LOGPROCESSOR,MORPHOVAULT, andMORPHOCOLLATERAL0x1f719d50287d50d75ef7f84a430a5168d0b2f8591debbac404f522687876cd52: Merkl returns a liveMORPHOSUPPLYcampaign with15%APR paid in JPYR0xe3df58f9d3011b7481ff36b939fa5f8da642f34ea5792d25d3958dbf1efa26d7: Merkl returns no live market-level campaign across the market campaign types the hook fetches0xe05faDf242331808f504661BEA65972594869826: Merkl returns a liveERC20LOGPROCESSORLENDopportunity with roughly7.6%APR paid in USDC, targeting the vault token/api/merklproxy routes returned200for campaign, vault opportunity, and/rewards/summarychecks; the route forwardsMERKL_API_KEYasX-API-Keywhen configured200:/vault/1/0xe05faDf242331808f504661BEA65972594869826Validation
npx ultracite fixnpx ultracite checkpnpm typecheckpnpm lint:checkgit diff --checkSummary by CodeRabbit
Bug Fixes
Documentation
Refactor
Revert