Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/components/incentives/IncentivesButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,8 +355,13 @@ const Content = ({
const hasPointsBreakdown = incentives.some(
(incentive) => (incentive as ExtendedReserveIncentiveResponse)?.breakdown?.points !== undefined
);
// A reserve whose only Merkl campaign is balance-based has a zero unconditional APY, but
// the badge still has to be there for the user to reach the tooltip.
const hasBalanceCampaign = incentives.some(
(incentive) => (incentive as ExtendedReserveIncentiveResponse)?.hasBalanceCampaign
);

if (incentivesNetAPR === 0 && !hasPointsBreakdown) {
if (incentivesNetAPR === 0 && !hasPointsBreakdown && !hasBalanceCampaign) {
return displayBlank ? <BlankIncentives /> : null;
}

Expand Down
30 changes: 21 additions & 9 deletions src/components/incentives/IncentivesCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,11 @@ export const IncentivesCard = ({
? merklPointsIncentives.breakdown.merklIncentivesAPR || 0
: merklIncentives?.breakdown?.merklIncentivesAPR || 0;

// Balance-based Merkl campaigns are excluded from `merklIncentivesAPR` by the hook: only
// positions above a minimum size earn them. Flag the value with an asterisk so the user
// knows there is extra APY to be found in the tooltip.
const hasBalanceCampaign = Boolean(merklIncentives?.hasBalanceCampaign);

const isBorrow = protocolAction === ProtocolAction.borrow;

// If any incentive is infinite, the total should be infinite
Expand Down Expand Up @@ -170,15 +175,22 @@ export const IncentivesCard = ({
∞ %
</Typography>
) : (
<FormattedNumber
data-cy={`apy`}
value={displayAPY}
percent
variant={variant}
symbolsVariant={symbolsVariant}
color={color}
symbolsColor={color}
/>
<Box sx={{ display: 'inline-flex', alignItems: 'flex-start' }}>
<FormattedNumber
data-cy={`apy`}
value={displayAPY}
percent
variant={variant}
symbolsVariant={symbolsVariant}
color={color}
symbolsColor={color}
/>
{hasBalanceCampaign && (
<Typography component="span" variant={variant} color={color || 'text.secondary'}>
*
</Typography>
)}
</Box>
)}
{tooltip}
{inlineIncentives && (
Expand Down
75 changes: 71 additions & 4 deletions src/components/incentives/MerklIncentivesTooltipContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Trans } from '@lingui/macro';
import { Box, Typography, useTheme } from '@mui/material';
import { Box, Tooltip, Typography, useTheme } from '@mui/material';
import { ExtendedReserveIncentiveResponse } from 'src/hooks/useMerklIncentives';

import { PointsBasedCampaignTooltip } from '../infoTooltips/PointsBasedCampaignTooltip';
Expand All @@ -21,6 +21,15 @@ export const MerklIncentivesTooltipContent = ({
const merklIncentivesFormatted = getSymbolMap(merklIncentives);

const isPointsBased = Boolean(merklIncentives?.breakdown?.points);
const balanceCampaignAPY = merklIncentives.breakdown.balanceCampaignAPY || 0;
// A reserve can be covered by a balance campaign alone, so the per-campaign rows must
// also be used when there is a single reward: the aggregated row cannot express that
// part of the APY is gated behind a minimum position size.
const hasBalanceCampaign = Boolean(merklIncentives.hasBalanceCampaign);
const perCampaignRewards = merklIncentives.rewardsTokensMappedApys ?? [];
const showPerCampaignRows =
perCampaignRewards.length > 1 || (hasBalanceCampaign && perCampaignRewards.length > 0);
const balanceCampaignMessage = merklIncentives.balanceCampaignMessage;
return (
<Box
sx={{
Expand Down Expand Up @@ -226,9 +235,8 @@ export const MerklIncentivesTooltipContent = ({
</Typography>
</Box>
</Row>
) : merklIncentives.rewardsTokensMappedApys &&
merklIncentives.rewardsTokensMappedApys.length > 1 ? (
merklIncentives.rewardsTokensMappedApys.map((reward, index) => {
) : showPerCampaignRows ? (
perCampaignRewards.map((reward, index) => {
const { tokenIconSymbol, symbol, aToken } = getSymbolMap({
rewardTokenSymbol: reward.token.symbol,
rewardTokenAddress: reward.token.address,
Expand All @@ -255,6 +263,24 @@ export const MerklIncentivesTooltipContent = ({
<Typography variant={typographyVariant} sx={{ ml: 0.5 }}>
{merklIncentives.breakdown.isBorrow ? '(-)' : '(+)'}
</Typography>
{reward.isBalanceCampaign && (
<Tooltip
title={
balanceCampaignMessage ||
'This reward is only earned by positions above a minimum size.'
}
arrow
placement="top"
>
<Typography
variant={typographyVariant}
color="warning.main"
sx={{ ml: 0.75, cursor: 'help', textDecoration: 'underline dotted' }}
>
<Trans>conditional</Trans>
</Typography>
</Tooltip>
)}
</Box>
}
width="100%"
Expand Down Expand Up @@ -338,6 +364,47 @@ export const MerklIncentivesTooltipContent = ({
</Typography>
</Box>
</Row>

{hasBalanceCampaign && balanceCampaignAPY > 0 && (
<>
<Row
height={32}
caption={
<Typography variant={typographyVariant} fontWeight={600}>
<Trans>With balance bonus</Trans>
</Typography>
}
width="100%"
>
<Box sx={{ display: 'inline-flex', alignItems: 'center' }}>
<FormattedNumber
value={
merklIncentives.breakdown.isBorrow
? merklIncentives.breakdown.totalAPY - balanceCampaignAPY
: merklIncentives.breakdown.totalAPY + balanceCampaignAPY
}
percent
variant={typographyVariant}
color="text.primary"
/>
<Typography variant={typographyVariant} sx={{ ml: 1 }} color="text.primary">
<Trans>APY</Trans>
</Typography>
</Box>
</Row>

<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
{balanceCampaignMessage ? (
<Trans>{balanceCampaignMessage}</Trans>
) : (
<Trans>
Part of these rewards is only earned by positions above a minimum size, so
the total APY shown in the markets and dashboard tables excludes it.
</Trans>
)}
</Typography>
</>
)}
</Box>
</>
) : (
Expand Down
58 changes: 50 additions & 8 deletions src/hooks/useMerklIncentives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ type ReserveIncentiveAdditionalData = {
customClaimMessage?: string;
customMessage?: string;
customForumLink?: string;
balanceCampaignMessage?: string;
};

export type ExtendedReserveIncentiveResponse = ReserveIncentiveResponse &
Expand Down Expand Up @@ -120,14 +121,22 @@ export type ExtendedReserveIncentiveResponse = ReserveIncentiveResponse &
campaignId: string;
dailyRewardsRecordId: string;
onChainCampaignId: string;
apr: number;
apy: number;
isBalanceCampaign: boolean;
}[];
hasBalanceCampaign?: boolean;
balanceCampaignAPY?: number;
};

export type MerklIncentivesBreakdown = {
protocolAPY: number;
protocolIncentivesAPR: number;
merklIncentivesAPR: number; // Now represents APY (converted from APR)
// Extra APY from balance-based campaigns. Deliberately excluded from
// `merklIncentivesAPR` and `totalAPY`: only positions above the campaign's minimum
// size earn it, so it must not inflate the headline APY.
balanceCampaignAPY: number;
totalAPY: number;
isBorrow: boolean;
breakdown: {
Expand Down Expand Up @@ -240,9 +249,19 @@ export const useMerklIncentives = ({
return null;
}

const balanceCampaignTokensSet = new Set(
(EXTRA_WHITELIST_TOKENS.balanceCampaignRewardTokens ?? [])
.filter(Boolean)
.map((token) => token.toLowerCase())
);

// Balance campaign payout tokens are whitelisted implicitly: listing them once in
// `balanceCampaignRewardTokens` is enough. Keeping them out of the whitelist would
// count their APR in the total with no row to explain it.
const whitelistedTokensSet = new Set(
[
...EXTRA_WHITELIST_TOKENS.whitelistedRewardTokens.map((token) => token.toLowerCase()),
...balanceCampaignTokensSet,
].filter(Boolean)
);

Expand Down Expand Up @@ -273,7 +292,6 @@ export const useMerklIncentives = ({
return sum + oppApr;
}, 0);

const merklIncentivesAPY = convertAprToApy(totalMerklAPR);
const aprsBreakdowns = whitelistedOpportunities.flatMap((opp) => opp.aprRecord.breakdowns);
const breakdownTokens = whitelistedOpportunities.flatMap((opp) => {
return opp.rewardsRecord.breakdowns;
Expand All @@ -286,22 +304,43 @@ export const useMerklIncentives = ({
return isWhitelisted && reward.onChainCampaignId === aprBreakdown.identifier;
});
if (matchingReward) {
const apr = getCampaignIncentiveApr({
targetAprPercent: aprBreakdown.value,
distributionType: aprBreakdown.distributionType,
protocolAction,
baseProtocolApy: protocolAPY,
});

return {
...matchingReward,
apy: convertAprToApy(
getCampaignIncentiveApr({
targetAprPercent: aprBreakdown.value,
distributionType: aprBreakdown.distributionType,
protocolAction,
baseProtocolApy: protocolAPY,
})
apr,
apy: convertAprToApy(apr),
isBalanceCampaign: balanceCampaignTokensSet.has(
matchingReward.token.address.toLowerCase()
),
};
}
return null;
})
.filter((item): item is NonNullable<typeof item> => item !== null);

// Balance-based campaigns pay out through a dedicated wrapper token, so their
// tranche is carved out of the total and surfaced separately: the headline APY must
// only include what every position earns.
const balanceCampaignRewards = rewardsTokensMappedApys.filter(
(reward) => reward.isBalanceCampaign
);
const balanceCampaignAPR = balanceCampaignRewards.reduce(
(sum, reward) => sum + reward.apr,
0
);
const balanceCampaignAPY = balanceCampaignRewards.reduce(
(sum, reward) => sum + reward.apy,
0
);
const hasBalanceCampaign = balanceCampaignRewards.length > 0;
const merklIncentivesAPY = convertAprToApy(Math.max(totalMerklAPR - balanceCampaignAPR, 0));

const primaryOpportunity = whitelistedOpportunities[0];
const isSelf = whitelistedOpportunities.some(isSelfOpportunity);
const rewardToken = primaryOpportunity.rewardsRecord.breakdowns[0].token;
Expand Down Expand Up @@ -329,10 +368,13 @@ export const useMerklIncentives = ({
...incentiveAdditionalData,
isSelf,
rewardsTokensMappedApys,
hasBalanceCampaign,
balanceCampaignAPY,
breakdown: {
protocolAPY,
protocolIncentivesAPR: protocolIncentivesAPY,
merklIncentivesAPR: merklIncentivesAPY,
balanceCampaignAPY,
totalAPY,
isBorrow,
breakdown: {
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useMerklPointsIncentives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export const useMerklPointsIncentives = ({
protocolAPY,
protocolIncentivesAPR: protocolIncentivesAPY,
merklIncentivesAPR: merklIncentivesAPY,
balanceCampaignAPY: 0,
totalAPY,
isBorrow,
breakdown: {
Expand Down
2 changes: 1 addition & 1 deletion src/locales/el/messages.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/locales/en/messages.js

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions src/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,10 @@ msgstr "You have assets with zero LTV that are blocking this operation. Please w
msgid "{0}"
msgstr "{0}"

#: src/components/incentives/MerklIncentivesTooltipContent.tsx
msgid "Part of these rewards is only earned by positions above a minimum size, so the total APY shown in the markets and dashboard tables excludes it."
msgstr "Part of these rewards is only earned by positions above a minimum size, so the total APY shown in the markets and dashboard tables excludes it."

#: src/components/transactions/Swap/warnings/postInputs/ZeroLTVDestinationWarning.tsx
msgid "{0} has a Loan-to-Value of 0, so it will not be enabled as collateral automatically after the swap."
msgstr "{0} has a Loan-to-Value of 0, so it will not be enabled as collateral automatically after the swap."
Expand Down Expand Up @@ -1708,6 +1712,10 @@ msgstr "{m}m"
msgid "Inquiry"
msgstr "Inquiry"

#: src/components/incentives/MerklIncentivesTooltipContent.tsx
msgid "With balance bonus"
msgstr "With balance bonus"

#: src/ui-config/errorMapping.tsx
msgid "Unbacked mint cap is exceeded"
msgstr "Unbacked mint cap is exceeded"
Expand Down Expand Up @@ -2630,6 +2638,10 @@ msgstr "Withdraw sGHO"
msgid "Asset"
msgstr "Asset"

#: src/components/incentives/MerklIncentivesTooltipContent.tsx
msgid "conditional"
msgstr "conditional"

#: src/modules/governance/proposal/ProposalOverview.tsx
msgid "Share on twitter"
msgstr "Share on twitter"
Expand Down Expand Up @@ -3639,6 +3651,10 @@ msgstr "Available to supply"
msgid "Claiming all protocol rewards and merit rewards together"
msgstr "Claiming all protocol rewards and merit rewards together"

#: src/components/incentives/MerklIncentivesTooltipContent.tsx
msgid "{balanceCampaignMessage}"
msgstr "{balanceCampaignMessage}"

#: src/modules/governance/proposal/ProposalTimeline.tsx
msgid "Voting activated"
msgstr "Voting activated"
Expand Down Expand Up @@ -3835,6 +3851,7 @@ msgstr "Selected supply assets"
#: src/components/incentives/MerklIncentivesTooltipContent.tsx
#: src/components/incentives/MerklIncentivesTooltipContent.tsx
#: src/components/incentives/MerklIncentivesTooltipContent.tsx
#: src/components/incentives/MerklIncentivesTooltipContent.tsx
#: src/modules/dashboard/lists/BorrowedPositionsList/BorrowedPositionsList.tsx
#: src/modules/dashboard/lists/BorrowedPositionsList/BorrowedPositionsList.tsx
#: src/modules/dashboard/lists/BorrowedPositionsList/BorrowedPositionsListItem.tsx
Expand Down
2 changes: 1 addition & 1 deletion src/locales/es/messages.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/locales/fr/messages.js

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions src/ui-config/merklConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ type AdditionalIncentiveInfo = {
customMessage?: string;
customClaimMessage?: string;
customForumLink?: string;
// Copy explaining the minimum position size required to earn the extra bps of a
// balance-based Merkl campaign. Written manually per reserve, since the threshold
// is not exposed by the Merkl API.
balanceCampaignMessage?: string;
};

type MerklWhitelistConfig = {
whitelistedRewardTokens: string[];
balanceCampaignRewardTokens: string[];
additionalIncentiveInfo: Record<string, AdditionalIncentiveInfo>;
};

Expand Down Expand Up @@ -561,6 +566,18 @@ export const EXTRA_WHITELIST_TOKENS: MerklWhitelistConfig = {
'0x58a6d21fd258166FF058bb4488a77B7308DDd514',
'0x09E4C43B7Ce78AC58fF6A920f1fD5D84a7975f78',
],
// Wrapper reward tokens deployed exclusively as the payout of balance-based Merkl
// campaigns (extra bps on top of the regular campaign, gated by a minimum position
// size). Their presence in a campaign's rewards is what flags the reserve as having
// a balance campaign.
//
// These are whitelisted implicitly, so an address goes in here *instead of* in
// `whitelistedRewardTokens` above, never in both. When adding one:
// 1. add an entry to `IncentivesSymbolMap` in IncentivesTooltipContent.tsx so the
// row renders the economic token's icon instead of the wrapper symbol;
// 2. add `balanceCampaignMessage` in `additionalIncentiveInfo` for every reserve
// the campaign targets, with the actual threshold.
balanceCampaignRewardTokens: [],
additionalIncentiveInfo: {
'1-0x4F5923Fc5FD4a93352581b38B7cD26943012DECF': {
customMessage:
Expand Down
Loading