feat: add Merkl rewards functionality and localization#39901
Conversation
|
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
✨ Files requiring CODEOWNER review ✨✅ @MetaMask/confirmations (12 files, +417 -1)
💎 @MetaMask/metamask-assets (3 files, +32 -2)
👨🔧 @MetaMask/metamask-earn (9 files, +1317 -0)
|
Builds ready [552723f]
UI Startup Metrics (1422 ± 90 ms)
📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
ui/components/app/assets/merkl-rewards/hooks/useMerklRewards.ts
Outdated
Show resolved
Hide resolved
Builds ready [45df3e2]
UI Startup Metrics (1410 ± 95 ms)
📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
ui/components/app/assets/merkl-rewards/hooks/useMerklRewards.ts
Outdated
Show resolved
Hide resolved
ui/components/app/assets/merkl-rewards/hooks/useMerklRewards.ts
Outdated
Show resolved
Hide resolved
Builds ready [e3f2a69]
UI Startup Metrics (1366 ± 110 ms)
📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
| > | ||
| {label} | ||
| </Text> | ||
| </span> |
There was a problem hiding this comment.
Non-accessible span used as clickable interactive element
Medium Severity
The clickable "Claim bonus" badge uses a raw <span> with an onClick handler. This element is not focusable via keyboard, won't respond to Enter/Space key presses, and screen readers won't announce it as interactive. A <button> (with appropriate styling) would provide built-in keyboard and accessibility support. This was also flagged in the PR discussion as "mess left by cursor."
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Builds ready [b2fa23b]
UI Startup Metrics (1430 ± 102 ms)
📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
| {t('merklRewardsUnexpectedError')} | ||
| </Text> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Error state renders non-interactive element blocking retry
Medium Severity
When useMerklClaim sets error, ClaimBonusBadge renders a non-interactive <Text> element displaying "Unexpected error. Please try again." — but the clickable <button> is not rendered in this branch, so the user has no way to actually retry. The error state is only cleared inside claimRewards() (line 71 of useMerklClaim.ts), which can only be invoked via the button's onClick. This creates an irrecoverable dead-end state within the component. The error message explicitly tells the user to "try again" but the UI provides no mechanism to do so without navigating away and remounting the component.
| networkClientId, | ||
| type: TransactionType.musdClaim, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Reward cache not cleared after successful claim dispatch
Medium Severity
After addTransactionAndRouteToConfirmationPage successfully dispatches, the 5-minute rewardCache in merkl-client.ts is not invalidated. If the user completes a claim, navigates back, and clicks the badge again within 5 minutes, stale cached proof data is used to build a new transaction. The on-chain contract would compute a zero payout (totalAmount − alreadyClaimed = 0), leading to a confusing confirmation screen and wasted gas if confirmed. Calling clearRewardCache() after successful dispatch would prevent this.
Additional Locations (1)
| * Transaction types that use the Pay flow (TransactionDetailsModal instead of TransactionListItemDetails) | ||
| */ | ||
| export const PAY_TRANSACTION_TYPES = [ | ||
| TransactionType.musdClaim, |
There was a problem hiding this comment.
Activity list shows wrong title for claim transactions
Medium Severity
Adding TransactionType.musdClaim to PAY_TRANSACTION_TYPES causes it to fall into the existing ternary in useTransactionDisplayData, which only distinguishes perpsDeposit from everything else. As a result, musdClaim transactions display the title musdConversionActivityTitle ("mUSD Conversion") in the activity list instead of a claim-specific label like "Claim bonus". No musdClaimActivityTitle locale key was added, and the branching logic was not updated to handle the new transaction type.
| {t('merklRewardsUnexpectedError')} | ||
| </Text> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Error state permanently replaces clickable badge with no retry
Medium Severity
When claimRewards() fails, useMerklClaim sets the error state, which causes ClaimBonusBadge to render a non-interactive <Text> element saying "Please try again." However, the clickable button is now gone, and the hook only clears error inside claimRewards() (line 71), which can no longer be invoked. The user is stuck in a dead-end error state with no retry path until the component unmounts.
Additional Locations (1)
| export const ELIGIBLE_TOKENS: Record<string, string[]> = { | ||
| [CHAIN_IDS.MAINNET]: [AGLAMERKL_ADDRESS_MAINNET, MUSD_TOKEN_ADDRESS], | ||
| [CHAIN_IDS.LINEA_MAINNET]: [AGLAMERKL_ADDRESS_LINEA, MUSD_TOKEN_ADDRESS], | ||
| '0xe709': [AGLAMERKL_ADDRESS_LINEA, MUSD_TOKEN_ADDRESS], |
There was a problem hiding this comment.
Unknown chain ID 0xe709 in eligible tokens map
Medium Severity
The ELIGIBLE_TOKENS map contains a hardcoded '0xe709' (decimal 59145) key, which is not a recognized chain ID. Linea Mainnet is 0xe708 (59144), already present in the map via CHAIN_IDS.LINEA_MAINNET. Linea Sepolia is 0xe705. The entry uses Linea-specific addresses, suggesting it was intended for a Linea chain but is off by one or is leftover development/test code.
Builds ready [cf911d4]
UI Startup Metrics (1439 ± 92 ms)
📊 Page Load Benchmark ResultsCurrent Commit: 📄 Localhost MetaMask Test DappSamples: 100 Summary
📈 Detailed Results
Bundle size diffs [🚨 Warning! Bundle size has increased!]
|
| privacyMode={privacyMode} | ||
| onClick={isNonEvmTestnet ? undefined : handleTokenClick(token)} | ||
| safeChains={safeChains} | ||
| showMerklBadge |
There was a problem hiding this comment.
(non-blocking nit) - if this is always true, maybe we can just kill this prop 😅
Not blocking, maybe we can clean up separately.


Description
Adds mUSD Merkl rewards claiming support to the extension, allowing users to view and claim mUSD conversion bonuses directly from the asset details page. This mirrors the feature recently added in metamask-mobile, adapted to the extension's architecture and patterns.
Why: Users holding mUSD earn conversion bonuses distributed via Merkl on Linea. They currently have no way to discover or claim these rewards within the extension.
What's included:
Key architectural decisions:
Changelog
CHANGELOG entry: Added the ability to view and claim mUSD Merkl conversion bonuses from the token list and asset details page (behind feature flag)
Related issues
Fixes:
https://consensyssoftware.atlassian.net/browse/MUSD-299
https://consensyssoftware.atlassian.net/browse/MUSD-300
https://consensyssoftware.atlassian.net/browse/MUSD-301
Manual testing steps
Screenshots/Recordings
Before
After
claim-extension-1.mov
Pre-merge author checklist
Pre-merge reviewer checklist
Note
Medium Risk
Adds a new transaction creation path (external API + on-chain read) and wires it into the confirmation flow for
TransactionType.musdClaim, which could impact user transaction routing and displayed claim amounts if incorrect.Overview
Adds a Merkl rewards “Claim bonus” entry point to token cells (token list, asset page, DeFi details) that appears only when the remote flag
earnMerklCampaignClaimingis enabled and the token is in an eligible allowlist.Introduces a new Merkl claim flow: fetches Merkl proof data from
api.merkl.xyz, builds/dispatches aTransactionType.musdClaimdistributorclaim()transaction on Linea, and adds a dedicated redesigned confirmation/info UI (title/subtitle, account row, receive summary handling). The claim confirmation computes the actual unclaimed amount by reading the distributor’sclaimed()value on-chain via a JSON-RPCeth_call(Infura URL fromFEATURED_RPCS) and formats token/fiat amounts.Written by Cursor Bugbot for commit cf911d4. This will update automatically on new commits. Configure here.