diff --git a/.changeset/olive-pandas-shave.md b/.changeset/olive-pandas-shave.md new file mode 100644 index 0000000000..0117f12ec3 --- /dev/null +++ b/.changeset/olive-pandas-shave.md @@ -0,0 +1,5 @@ +--- +"@venusprotocol/evm": minor +--- + +display the per-user reward APY of collateral-gated Merkl borrow campaigns diff --git a/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts b/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts index a78a066c8c..06379b600d 100644 --- a/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts +++ b/apps/evm/src/clients/api/queries/getSimulatedPool/index.ts @@ -17,6 +17,7 @@ import { calculateYearlyEarningsForAssets, clampToZero, } from 'utilities'; +import { withMerklCollateralGates } from '../useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates'; import { addUserPrimeApys } from './addUserPrimeApys'; export interface GetSimulatedPoolInput { @@ -213,6 +214,10 @@ export const getSimulatedPool = async ({ }); } + // Collateral-gated Merkl rewards depend on the user's position, so they have to be resolved + // again against the simulated balances before earnings are derived from them + simulatedAssets = withMerklCollateralGates({ assets: simulatedAssets }); + const userPoolValues = calculateUserPoolValues({ assets: simulatedAssets, userVaiBorrowBalanceCents, diff --git a/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/index.ts b/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/index.ts index 5e03c03835..c3e94e4af0 100644 --- a/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/index.ts +++ b/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/index.ts @@ -17,6 +17,7 @@ import { getUserCollateralAddresses } from './getUserCollateralAddresses'; import { getUserPrimeApys } from './getUserPrimeApys'; import { getUserTokenBalances } from './getUserTokenBalances'; import { getUserVaiBorrowBalance } from './getUserVaiBorrowBalance'; +import { withMerklCollateralGates } from './withMerklCollateralGates'; export interface GetPoolsQueryOutput extends GetPoolsOutput { tokenMetadataMapping: Record; @@ -197,7 +198,7 @@ export const getPools = async ({ userLegacyPoolEModeGroupId; } - const pools = formatOutput({ + const formattedPools = formatOutput({ chainId, isUserConnected: !!accountAddress, tokens, @@ -214,6 +215,11 @@ export const getPools = async ({ vaiPriceMantissa: vaiPriceMantissaResult, }); + const pools = formattedPools.map(pool => { + const assets = withMerklCollateralGates({ assets: pool.assets }); + return assets === pool.assets ? pool : { ...pool, assets }; + }); + // Add Prime simulations // TODO: get Prime simulations from API const xvs = tokens.find(token => token.symbol === 'XVS'); diff --git a/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates/__tests__/index.spec.ts b/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates/__tests__/index.spec.ts new file mode 100644 index 0000000000..82789f8ffb --- /dev/null +++ b/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates/__tests__/index.spec.ts @@ -0,0 +1,249 @@ +import BigNumber from 'bignumber.js'; + +import { assetData } from '__mocks__/models/asset'; +import { isolatedPool, legacyCorePool } from '__mocks__/models/pools'; +import { xvs } from '__mocks__/models/tokens'; +import { vUsdc, vUsdtCorePool, vXvs } from '__mocks__/models/vTokens'; +import type { Asset, MerklDistribution, Pool, VToken } from 'types'; + +import { withMerklCollateralGates } from '..'; + +const buildGatedDistribution = (): MerklDistribution => ({ + type: 'merkl', + token: xvs, + apyPercentage: new BigNumber(20), + dailyDistributedTokens: new BigNumber(0), + isActive: true, + rewardDetails: { + appName: 'Merkl', + claimUrl: 'https://app.merkl.xyz/', + marketAddress: vUsdtCorePool.address, + merklCampaignIdentifier: '0xfake', + description: 'Merkl campaign', + tags: [], + aprPercentage: 20, + participatingCollateralAddresses: [vXvs.address], + eligibleBorrowMarketAddresses: [vUsdtCorePool.address, vUsdc.address], + }, +}); + +const buildAsset = ({ vToken, ...overrides }: { vToken: VToken } & Partial): Asset => ({ + ...assetData[0], + vToken, + isCollateralOfUser: false, + userSupplyBalanceCents: new BigNumber(0), + userBorrowBalanceCents: new BigNumber(0), + supplyTokenDistributions: [], + borrowTokenDistributions: [], + ...overrides, +}); + +// $1000 of collateral at a 60% collateral factor, against $500 of USDT and $500 of USDC borrows +const buildPools = ({ + collateralCents = new BigNumber(100000), + isCollateralOfUser = true, + usdtBorrowCents = new BigNumber(50000), + usdcBorrowCents = new BigNumber(50000), +}: { + collateralCents?: BigNumber; + isCollateralOfUser?: boolean; + usdtBorrowCents?: BigNumber; + usdcBorrowCents?: BigNumber; +} = {}): Pool[] => [ + { + ...legacyCorePool, + assets: [ + buildAsset({ + vToken: vXvs, + isCollateralOfUser, + userSupplyBalanceCents: collateralCents, + userCollateralFactor: 0.6, + }), + buildAsset({ + vToken: vUsdtCorePool, + userBorrowBalanceCents: usdtBorrowCents, + borrowTokenDistributions: [buildGatedDistribution()], + }), + buildAsset({ + vToken: vUsdc, + userBorrowBalanceCents: usdcBorrowCents, + borrowTokenDistributions: [buildGatedDistribution()], + }), + ], + }, +]; + +const getGate = (assets: Asset[], assetIndex: number) => { + const distribution = assets[assetIndex].borrowTokenDistributions[0]; + return distribution.type === 'merkl' ? distribution : undefined; +}; + +describe('appendMerklCollateralGates', () => { + it('scales the campaign APR down by the share of the loan the collateral covers', () => { + const pools = buildPools(); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + const distribution = getGate(assets, 1); + expect(distribution?.collateralGate?.isUserEligible).toBe(true); + expect(distribution?.collateralGate?.maxApyPercentage.toFixed()).toBe('20'); + // 20% * min($600, $1000) / $1000 + expect(distribution?.apyPercentage.toFixed()).toBe('12'); + }); + + it('applies the same reward APY to every eligible borrow market of the campaign', () => { + const pools = buildPools(); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + expect(getGate(assets, 2)?.apyPercentage.toFixed()).toBe('12'); + }); + + it('awards the full campaign APR when the collateral covers the whole loan', () => { + const pools = buildPools({ collateralCents: new BigNumber(1000000) }); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + expect(getGate(assets, 1)?.apyPercentage.toFixed()).toBe('20'); + }); + + it('marks the user as ineligible when they hold none of the participating collateral', () => { + const pools = buildPools({ collateralCents: new BigNumber(0) }); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + const distribution = getGate(assets, 1); + expect(distribution?.collateralGate?.isUserEligible).toBe(false); + expect(distribution?.apyPercentage.toFixed()).toBe('0'); + }); + + it('marks the user as ineligible when the participating collateral is not enabled', () => { + const pools = buildPools({ isCollateralOfUser: false }); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + expect(getGate(assets, 1)?.collateralGate?.isUserEligible).toBe(false); + }); + + it('marks the user as ineligible when they borrow none of the eligible markets', () => { + const pools = buildPools({ + usdtBorrowCents: new BigNumber(0), + usdcBorrowCents: new BigNumber(0), + }); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + const distribution = getGate(assets, 1); + expect(distribution?.collateralGate?.isUserEligible).toBe(false); + expect(distribution?.apyPercentage.toFixed()).toBe('0'); + }); + + it('leaves ungated Merkl campaigns untouched', () => { + const ungatedDistribution: MerklDistribution = { + ...buildGatedDistribution(), + apyPercentage: new BigNumber(5), + rewardDetails: { + ...buildGatedDistribution().rewardDetails, + participatingCollateralAddresses: [], + eligibleBorrowMarketAddresses: [], + }, + }; + + const pools: Pool[] = [ + { + ...legacyCorePool, + assets: [ + buildAsset({ + vToken: vUsdtCorePool, + borrowTokenDistributions: [ungatedDistribution], + }), + ], + }, + ]; + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + const distribution = getGate(assets, 0); + expect(distribution?.collateralGate).toBeUndefined(); + expect(distribution?.apyPercentage.toFixed()).toBe('5'); + }); + + it('ignores collateral held in another pool, which cannot back the borrow', () => { + const pools: Pool[] = [ + { + ...legacyCorePool, + assets: [ + buildAsset({ + vToken: vUsdtCorePool, + userBorrowBalanceCents: new BigNumber(50000), + borrowTokenDistributions: [buildGatedDistribution()], + }), + ], + }, + { + ...isolatedPool, + assets: [ + buildAsset({ + vToken: vXvs, + isCollateralOfUser: true, + userSupplyBalanceCents: new BigNumber(100000), + userCollateralFactor: 0.6, + }), + ], + }, + ]; + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + const distribution = getGate(assets, 0); + expect(distribution?.collateralGate?.isUserEligible).toBe(false); + expect(distribution?.apyPercentage.toFixed()).toBe('0'); + }); + + it('leaves the campaign alone when it reports no rate', () => { + const noRateDistribution: MerklDistribution = { + ...buildGatedDistribution(), + apyPercentage: new BigNumber(0), + rewardDetails: { ...buildGatedDistribution().rewardDetails, aprPercentage: 0 }, + }; + + const pools = buildPools(); + pools[0].assets[1].borrowTokenDistributions = [noRateDistribution]; + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + expect(getGate(assets, 1)?.collateralGate).toBeUndefined(); + expect(getGate(assets, 1)?.apyPercentage.toFixed()).toBe('0'); + }); + + it('returns the exact same references when no pool asset carries a gated campaign', () => { + const pools = buildPools(); + pools[0].assets.forEach(asset => { + asset.borrowTokenDistributions = []; + }); + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + expect(assets).toBe(pools[0].assets); + }); + + it('never touches supply distributions or unrelated assets', () => { + const pools = buildPools(); + const collateralAsset = pools[0].assets[0]; + const supplyDistributions = collateralAsset.supplyTokenDistributions; + + const assets = withMerklCollateralGates({ assets: pools[0].assets }); + + expect(assets[0]).toBe(collateralAsset); + expect(assets[0].supplyTokenDistributions).toBe(supplyDistributions); + }); + + it('does not mutate the assets it was given', () => { + const pools = buildPools(); + const original = pools[0].assets[1].borrowTokenDistributions[0]; + + withMerklCollateralGates({ assets: pools[0].assets }); + + expect(original.type === 'merkl' && original.collateralGate).toBeUndefined(); + }); +}); diff --git a/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates/index.ts b/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates/index.ts new file mode 100644 index 0000000000..dae192ca64 --- /dev/null +++ b/apps/evm/src/clients/api/queries/useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates/index.ts @@ -0,0 +1,81 @@ +import BigNumber from 'bignumber.js'; + +import type { Asset } from 'types'; +import { areAddressesEqual } from 'utilities'; + +// Collateral only backs borrows made within the same pool, so callers pass the assets of a +// single pool. Returns the same references when nothing changed, to preserve referential equality. +export const withMerklCollateralGates = ({ assets }: { assets: Asset[] }): Asset[] => { + let changed = false; + + const updatedAssets = assets.map(asset => { + let assetChanged = false; + + const borrowTokenDistributions = asset.borrowTokenDistributions.map(distribution => { + if (distribution.type !== 'merkl') { + return distribution; + } + + const { + aprPercentage = 0, + participatingCollateralAddresses = [], + eligibleBorrowMarketAddresses = [], + } = distribution.rewardDetails; + + const maxApyPercentage = new BigNumber(aprPercentage); + + // Leaving the gate unset hides the badge and the reward row: there is nothing to + // advertise when the campaign is not gated or reports no rate + if (eligibleBorrowMarketAddresses.length === 0 || !maxApyPercentage.isGreaterThan(0)) { + return distribution; + } + + const collateralCents = assets.reduce((acc, collateralAsset) => { + const isParticipating = participatingCollateralAddresses.some(address => + areAddressesEqual(address, collateralAsset.vToken.address), + ); + + return collateralAsset.isCollateralOfUser && isParticipating + ? acc.plus( + collateralAsset.userSupplyBalanceCents.multipliedBy( + collateralAsset.userCollateralFactor, + ), + ) + : acc; + }, new BigNumber(0)); + + const eligibleLoanCents = assets.reduce( + (acc, borrowAsset) => + eligibleBorrowMarketAddresses.some(address => + areAddressesEqual(address, borrowAsset.vToken.address), + ) + ? acc.plus(borrowAsset.userBorrowBalanceCents) + : acc, + new BigNumber(0), + ); + + const isUserEligible = collateralCents.isGreaterThan(0) && eligibleLoanCents.isGreaterThan(0); + + assetChanged = true; + + return { + ...distribution, + collateralGate: { isUserEligible, maxApyPercentage }, + apyPercentage: isUserEligible + ? maxApyPercentage + .multipliedBy(BigNumber.minimum(collateralCents, eligibleLoanCents)) + .dividedBy(eligibleLoanCents) + : new BigNumber(0), + }; + }); + + if (!assetChanged) { + return asset; + } + + changed = true; + return { ...asset, borrowTokenDistributions }; + }); + + return changed ? updatedAssets : assets; +}; diff --git a/apps/evm/src/components/Apy/BoostTooltip/index.tsx b/apps/evm/src/components/Apy/BoostTooltip/index.tsx index cf9ccadb49..0329c2a6ce 100644 --- a/apps/evm/src/components/Apy/BoostTooltip/index.tsx +++ b/apps/evm/src/components/Apy/BoostTooltip/index.tsx @@ -1,22 +1,10 @@ import { cn } from '@venusprotocol/ui'; -import type BigNumber from 'bignumber.js'; import { Tooltip, type TooltipProps } from 'components'; -import { Link } from 'containers/Link'; import { useTranslation } from 'libs/translations'; -import type { PointDistribution, Token, TokenDistribution } from 'types'; -import { formatPercentageToReadableValue } from 'utilities'; -import { Distribution, type DistributionProps } from './Distribution'; +import { DistributionList, type DistributionListProps } from '../DistributionList'; import starsIconSrc from './stars.svg'; -export interface BoostTooltipProps extends Omit { - type: 'supply' | 'borrow'; - token: Token; - baseApyPercentage: BigNumber; - userBalanceTokens?: BigNumber; - tokenDistributions: TokenDistribution[]; - pointDistributions: PointDistribution[]; - primeApyPercentage?: BigNumber; -} +export interface BoostTooltipProps extends Omit, DistributionListProps {} export const BoostTooltip: React.FC = ({ className, @@ -30,148 +18,21 @@ export const BoostTooltip: React.FC = ({ children, ...otherProps }) => { - const { t, Trans } = useTranslation(); - - const listItems: DistributionProps[] = [ - { - name: - type === 'supply' - ? t('apy.boost.tooltip.supplyApy.name') - : t('apy.boost.tooltip.borrowApy.name'), - value: formatPercentageToReadableValue(baseApyPercentage), - logoSrc: token.iconSrc, - description: - type === 'supply' - ? t('apy.boost.tooltip.supplyApy.description') - : t('apy.boost.tooltip.borrowApy.description'), - }, - ]; - - tokenDistributions.forEach(d => { - // Filter out 0% distributions - if (d.apyPercentage.isEqualTo(0)) { - return; - } - - if (d.type === 'merkl') { - const distribution: DistributionProps = { - name: d.rewardDetails.description || t('apy.boost.tooltip.defaultMerklRewardName'), - value: formatPercentageToReadableValue(d.apyPercentage), - logoSrc: d.token.iconSrc, - description: ( - e.stopPropagation()} - /> - ), - }} - /> - ), - }; - - return listItems.push(distribution); - } - - if (d.type === 'venus') { - const distribution: DistributionProps = { - name: t('apy.boost.tooltip.xvsDistribution.name'), - description: t('apy.boost.tooltip.xvsDistribution.description'), - value: formatPercentageToReadableValue(d.apyPercentage), - logoSrc: d.token.iconSrc, - }; - - return listItems.push(distribution); - } - - if (d.type === 'intrinsic') { - const distribution: DistributionProps = { - name: t('apy.boost.tooltip.intrinsicApy.name'), - description: t('apy.boost.tooltip.intrinsicApy.description'), - value: formatPercentageToReadableValue(d.apyPercentage), - logoSrc: d.token.iconSrc, - }; - - return listItems.push(distribution); - } - - if (d.type === 'off-chain') { - const distribution: DistributionProps = { - name: t('apy.boost.tooltip.offChainApy.name'), - description: t('apy.boost.tooltip.offChainApy.description'), - value: formatPercentageToReadableValue(d.apyPercentage), - logoSrc: d.token.iconSrc, - }; - - return listItems.push(distribution); - } - - if (d.type === 'yield-to-maturity') { - const distribution: DistributionProps = { - name: t('apy.boost.tooltip.yieldToMaturityApy.name'), - description: t('apy.boost.tooltip.yieldToMaturityApy.description'), - value: formatPercentageToReadableValue(d.apyPercentage), - logoSrc: d.token.iconSrc, - }; - - return listItems.push(distribution); - } - - if (d.type === 'liquidity-hub-intrinsic') { - const distribution: DistributionProps = { - name: t('apy.boost.tooltip.liquidityHubIntrinsicApy.name'), - description: t('apy.boost.tooltip.liquidityHubIntrinsicApy.description'), - value: formatPercentageToReadableValue(d.apyPercentage), - logoSrc: d.token.iconSrc, - }; - - return listItems.push(distribution); - } - }, []); - - // Add Prime distribution - if (primeApyPercentage && userBalanceTokens?.isGreaterThan(0)) { - listItems.push({ - name: t('apy.boost.tooltip.primeDistribution.name'), - description: t('apy.boost.tooltip.primeDistribution.description'), - value: formatPercentageToReadableValue(primeApyPercentage), - logoSrc: token.iconSrc, - }); - } - - pointDistributions.forEach(p => - listItems.push({ - name: p.title, - value: p.incentive, - logoSrc: p.logoUrl, - description: - !!p.description || !!p.extraInfoUrl ? ( -
-

{p.description}

- - {!!p.extraInfoUrl && ( - e.stopPropagation()} target="_blank"> - {t('apy.boost.tooltip.pointDistribution.learnMore')} - - )} -
- ) : undefined, - }), - ); + const { t } = useTranslation(); return ( - {listItems.map(t => ( - - ))} - + } {...otherProps} > diff --git a/apps/evm/src/components/Apy/BoostTooltip/Distribution/index.tsx b/apps/evm/src/components/Apy/DistributionList/Distribution/index.tsx similarity index 100% rename from apps/evm/src/components/Apy/BoostTooltip/Distribution/index.tsx rename to apps/evm/src/components/Apy/DistributionList/Distribution/index.tsx diff --git a/apps/evm/src/components/Apy/DistributionList/index.tsx b/apps/evm/src/components/Apy/DistributionList/index.tsx new file mode 100644 index 0000000000..cdcf392806 --- /dev/null +++ b/apps/evm/src/components/Apy/DistributionList/index.tsx @@ -0,0 +1,183 @@ +import type BigNumber from 'bignumber.js'; +import { Link } from 'containers/Link'; +import { useTranslation } from 'libs/translations'; +import type { PointDistribution, Token, TokenDistribution } from 'types'; +import { formatDistributionApyToReadableValue, formatPercentageToReadableValue } from 'utilities'; +import { Distribution, type DistributionProps } from './Distribution'; + +export interface DistributionListProps { + type: 'supply' | 'borrow'; + token: Token; + baseApyPercentage: BigNumber; + tokenDistributions: TokenDistribution[]; + pointDistributions: PointDistribution[]; + userBalanceTokens?: BigNumber; + primeApyPercentage?: BigNumber; +} + +export const DistributionList: React.FC = ({ + type, + token, + baseApyPercentage, + userBalanceTokens, + primeApyPercentage, + tokenDistributions, + pointDistributions, +}) => { + const { t, Trans } = useTranslation(); + + const formatDistributionApy = (apyPercentage: BigNumber) => + formatDistributionApyToReadableValue({ apyPercentage, type }); + + const renderExternalRewardDescription = (claimUrl: string) => ( + e.stopPropagation()} />, + }} + /> + ); + + const listItems: DistributionProps[] = [ + { + name: + type === 'supply' + ? t('apy.boost.tooltip.supplyApy.name') + : t('apy.boost.tooltip.borrowApy.name'), + value: formatPercentageToReadableValue(baseApyPercentage), + logoSrc: token.iconSrc, + description: + type === 'supply' + ? t('apy.boost.tooltip.supplyApy.description') + : t('apy.boost.tooltip.borrowApy.description'), + }, + ]; + + tokenDistributions.forEach(d => { + const collateralGate = d.type === 'merkl' ? d.collateralGate : undefined; + const isMissingRequiredCollateral = !!collateralGate && !collateralGate.isUserEligible; + + // Filter out 0% distributions, unless the user is only missing the required collateral + if (d.apyPercentage.isEqualTo(0) && !isMissingRequiredCollateral) { + return; + } + + if (d.type === 'merkl') { + const distribution: DistributionProps = { + name: d.rewardDetails.description || t('apy.boost.tooltip.defaultMerklRewardName'), + value: formatDistributionApy( + isMissingRequiredCollateral ? collateralGate.maxApyPercentage : d.apyPercentage, + ), + logoSrc: d.token.iconSrc, + description: isMissingRequiredCollateral ? ( + <> +

+ {t('apy.boost.tooltip.collateralGatedMerklReward.description', { + tokenSymbol: token.symbol, + })} +

+ +

{renderExternalRewardDescription(d.rewardDetails.claimUrl)}

+ + ) : ( + renderExternalRewardDescription(d.rewardDetails.claimUrl) + ), + }; + + return listItems.push(distribution); + } + + if (d.type === 'venus') { + const distribution: DistributionProps = { + name: t('apy.boost.tooltip.xvsDistribution.name'), + description: t('apy.boost.tooltip.xvsDistribution.description'), + value: formatDistributionApy(d.apyPercentage), + logoSrc: d.token.iconSrc, + }; + + return listItems.push(distribution); + } + + if (d.type === 'intrinsic') { + const distribution: DistributionProps = { + name: t('apy.boost.tooltip.intrinsicApy.name'), + description: t('apy.boost.tooltip.intrinsicApy.description'), + value: formatDistributionApy(d.apyPercentage), + logoSrc: d.token.iconSrc, + }; + + return listItems.push(distribution); + } + + if (d.type === 'off-chain') { + const distribution: DistributionProps = { + name: t('apy.boost.tooltip.offChainApy.name'), + description: t('apy.boost.tooltip.offChainApy.description'), + value: formatDistributionApy(d.apyPercentage), + logoSrc: d.token.iconSrc, + }; + + return listItems.push(distribution); + } + + if (d.type === 'yield-to-maturity') { + const distribution: DistributionProps = { + name: t('apy.boost.tooltip.yieldToMaturityApy.name'), + description: t('apy.boost.tooltip.yieldToMaturityApy.description'), + value: formatDistributionApy(d.apyPercentage), + logoSrc: d.token.iconSrc, + }; + + return listItems.push(distribution); + } + + if (d.type === 'liquidity-hub-intrinsic') { + const distribution: DistributionProps = { + name: t('apy.boost.tooltip.liquidityHubIntrinsicApy.name'), + description: t('apy.boost.tooltip.liquidityHubIntrinsicApy.description'), + value: formatDistributionApy(d.apyPercentage), + logoSrc: d.token.iconSrc, + }; + + return listItems.push(distribution); + } + }); + + // Add Prime distribution + if (primeApyPercentage && userBalanceTokens?.isGreaterThan(0)) { + listItems.push({ + name: t('apy.boost.tooltip.primeDistribution.name'), + description: t('apy.boost.tooltip.primeDistribution.description'), + value: formatDistributionApy(primeApyPercentage), + logoSrc: token.iconSrc, + }); + } + + pointDistributions.forEach(p => + listItems.push({ + name: p.title, + value: p.incentive, + logoSrc: p.logoUrl, + description: + !!p.description || !!p.extraInfoUrl ? ( +
+

{p.description}

+ + {!!p.extraInfoUrl && ( + e.stopPropagation()} target="_blank"> + {t('apy.boost.tooltip.pointDistribution.learnMore')} + + )} +
+ ) : undefined, + }), + ); + + return ( +
+ {listItems.map(item => ( + + ))} +
+ ); +}; diff --git a/apps/evm/src/components/Apy/MerklBadge/MerklIcon/index.tsx b/apps/evm/src/components/Apy/MerklBadge/MerklIcon/index.tsx new file mode 100644 index 0000000000..1e2cddaac5 --- /dev/null +++ b/apps/evm/src/components/Apy/MerklBadge/MerklIcon/index.tsx @@ -0,0 +1,18 @@ +import { cn } from '@venusprotocol/ui'; +import { useTranslation } from 'libs/translations'; +import merklLogoSrc from './merklLogo.svg'; + +export type MerklIconProps = Omit, 'alt' | 'src'>; + +export const MerklIcon: React.FC = ({ className, ...otherProps }) => { + const { t } = useTranslation(); + + return ( + {t('apy.merklBadge.logoAlt')} + ); +}; diff --git a/apps/evm/src/components/Apy/MerklBadge/MerklIcon/merklLogo.svg b/apps/evm/src/components/Apy/MerklBadge/MerklIcon/merklLogo.svg new file mode 100644 index 0000000000..d1ba0ac62c --- /dev/null +++ b/apps/evm/src/components/Apy/MerklBadge/MerklIcon/merklLogo.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/evm/src/components/Apy/MerklBadge/index.tsx b/apps/evm/src/components/Apy/MerklBadge/index.tsx new file mode 100644 index 0000000000..33ea575b44 --- /dev/null +++ b/apps/evm/src/components/Apy/MerklBadge/index.tsx @@ -0,0 +1,63 @@ +import { cn } from '@venusprotocol/ui'; +import type BigNumber from 'bignumber.js'; + +import { SenaryButton, Tooltip, type TooltipProps } from 'components'; +import useFormatPercentageToReadableValue from 'hooks/useFormatPercentageToReadableValue'; +import { useTranslation } from 'libs/translations'; +import { DistributionList, type DistributionListProps } from '../DistributionList'; +import { MerklIcon } from './MerklIcon'; + +export interface MerklBadgeProps + extends Omit, + DistributionListProps { + simulatedApyPercentage: BigNumber; +} + +export const MerklBadge: React.FC = ({ + simulatedApyPercentage, + type, + token, + baseApyPercentage, + userBalanceTokens, + primeApyPercentage, + tokenDistributions, + pointDistributions, + className, + ...otherProps +}) => { + const { t } = useTranslation(); + + const readableApy = useFormatPercentageToReadableValue({ + value: simulatedApyPercentage, + }); + + return ( + +

{t('apy.merklBadge.tooltip.leadLine', { apy: readableApy })}

+ + + + } + {...otherProps} + > + + + + + {readableApy} + + +
+ ); +}; diff --git a/apps/evm/src/components/Apy/__tests__/index.spec.tsx b/apps/evm/src/components/Apy/__tests__/index.spec.tsx index 551c661739..eb067d4bc6 100644 --- a/apps/evm/src/components/Apy/__tests__/index.spec.tsx +++ b/apps/evm/src/components/Apy/__tests__/index.spec.tsx @@ -16,6 +16,29 @@ const venusDistribution: TokenDistribution = { isActive: true, }; +const buildGatedMerklDistribution = (isUserEligible: boolean): TokenDistribution => ({ + type: 'merkl', + token, + apyPercentage: isUserEligible ? new BigNumber(3) : new BigNumber(0), + dailyDistributedTokens: new BigNumber(0), + isActive: true, + collateralGate: { + isUserEligible, + maxApyPercentage: new BigNumber(7), + }, + rewardDetails: { + appName: 'Merkl', + claimUrl: 'https://app.merkl.xyz/', + marketAddress: assetData[0].vToken.address, + merklCampaignIdentifier: '0xfake', + description: 'Merkl campaign', + tags: [], + aprPercentage: 7, + participatingCollateralAddresses: ['0x0000000000000000000000000000000000000001'], + eligibleBorrowMarketAddresses: [assetData[0].vToken.address], + }, +}); + describe('Apy', () => { it('renders a base APY without a boost', () => { const { getByText, queryByAltText } = renderComponent( @@ -163,4 +186,64 @@ describe('Apy', () => { expect(getByText('3%')).toBeInTheDocument(); expect(getByAltText(en.apy.primeBadge.logoAlt)).toBeInTheDocument(); }); + + it('renders the base borrow APY plus a Merkl badge when the user is missing the required collateral', () => { + const { getByAltText, getByText, queryByAltText } = renderComponent( + , + ); + + expect(getByText('-2%')).toBeInTheDocument(); + // -2% - 7% + expect(getByText('-9%')).toBeInTheDocument(); + expect(getByAltText(en.apy.merklBadge.logoAlt)).toBeInTheDocument(); + expect(queryByAltText(en.apy.boost.iconAlt)).not.toBeInTheDocument(); + }); + + it('folds the Merkl reward into the borrow APY once the user is eligible', () => { + const { getByAltText, getByText, queryByAltText } = renderComponent( + , + ); + + // -2% - 3% + expect(getByText('-5%')).toBeInTheDocument(); + expect(getByAltText(en.apy.boost.iconAlt)).toBeInTheDocument(); + expect(queryByAltText(en.apy.merklBadge.logoAlt)).not.toBeInTheDocument(); + }); + + it('hides the Prime badge when a Merkl badge is also displayed', () => { + const { getByAltText, queryByAltText } = renderComponent( + , + ); + + expect(getByAltText(en.apy.merklBadge.logoAlt)).toBeInTheDocument(); + expect(queryByAltText(en.apy.primeBadge.logoAlt)).not.toBeInTheDocument(); + }); }); diff --git a/apps/evm/src/components/Apy/index.tsx b/apps/evm/src/components/Apy/index.tsx index 8dddad786a..91b60490ef 100644 --- a/apps/evm/src/components/Apy/index.tsx +++ b/apps/evm/src/components/Apy/index.tsx @@ -1,6 +1,7 @@ import { cn } from '@venusprotocol/ui'; import type BigNumber from 'bignumber.js'; import type { + MerklDistribution, PointDistribution, PrimeDistribution, PrimeSimulationDistribution, @@ -9,6 +10,7 @@ import type { } from 'types'; import { formatPercentageToReadableValue, getCombinedApy } from 'utilities'; import { BoostTooltip } from './BoostTooltip'; +import { MerklBadge } from './MerklBadge'; import { PrimeBadge } from './PrimeBadge'; export interface ApyProps { @@ -42,6 +44,7 @@ export const Apy: React.FC = ({ const readableApy = formatPercentageToReadableValue(combinedApy.totalApyPercentage); let primeDistribution: PrimeDistribution | undefined; let primeSimulationDistribution: PrimeSimulationDistribution | undefined; + let gatedMerklDistribution: MerklDistribution | undefined; const activeTokenDistributions = tokenDistributions.filter(distribution => distribution.isActive); activeTokenDistributions.forEach(distribution => { @@ -49,6 +52,11 @@ export const Apy: React.FC = ({ primeDistribution = distribution; } else if (distribution.type === 'primeSimulation') { primeSimulationDistribution = distribution; + } else if ( + distribution.type === 'merkl' && + distribution.collateralGate?.isUserEligible === false + ) { + gatedMerklDistribution = distribution; } }); @@ -68,37 +76,58 @@ export const Apy: React.FC = ({ : combinedApy.totalApyPercentage.minus(combinedApy.apyPrimeSimulationPercentage); } + const distributionListProps = { + type, + token, + baseApyPercentage, + userBalanceTokens, + tokenDistributions: activeTokenDistributions, + pointDistributions, + primeApyPercentage: primeDistribution?.apyPercentage, + }; + + // The Merkl badge takes over the badge slot whenever both could show + let badgeDom: React.ReactNode; + + if (gatedMerklDistribution?.collateralGate) { + badgeDom = ( + + ); + } else if (showPrimeSimulation && isPrimeAsset && !isApyBoostedByPrime) { + badgeDom = ( + + ); + } + return (
- {isApyBoostedByPrime && } + {isApyBoostedByPrime && !gatedMerklDistribution && ( + + )} {isApyBoosted ? ( - +

{readableApy}

) : (

{readableApy}

)} - {showPrimeSimulation && isPrimeAsset && !isApyBoostedByPrime && ( - - )} + {badgeDom}
); }; diff --git a/apps/evm/src/components/ApyBreakdown/__tests__/index.spec.tsx b/apps/evm/src/components/ApyBreakdown/__tests__/index.spec.tsx index 85cdd0e433..b7cdff5922 100644 --- a/apps/evm/src/components/ApyBreakdown/__tests__/index.spec.tsx +++ b/apps/evm/src/components/ApyBreakdown/__tests__/index.spec.tsx @@ -52,7 +52,7 @@ describe('ApyBreakdown', () => { const { container } = renderComponent(); expect(container.textContent).toBe( - 'Borrow APY-4.97%Distribution APY0.52%Total borrow APY-6.49%', + 'Borrow APY-4.97%Distribution APY-0.52%Total borrow APY-6.49%', ); }); @@ -60,7 +60,7 @@ describe('ApyBreakdown', () => { const { container } = renderComponent(); expect(container.textContent).toBe( - 'Supply APY0.05%Distribution APY0.11%Borrow APY-4.97%Distribution APY0.52%Net APY7.66%', + 'Supply APY0.05%Distribution APY0.11%Borrow APY-4.97%Distribution APY-0.52%Net APY7.66%', ); }); diff --git a/apps/evm/src/components/ApyBreakdown/formatRows/index.tsx b/apps/evm/src/components/ApyBreakdown/formatRows/index.tsx index 0a1f11aa98..81956b1988 100644 --- a/apps/evm/src/components/ApyBreakdown/formatRows/index.tsx +++ b/apps/evm/src/components/ApyBreakdown/formatRows/index.tsx @@ -1,6 +1,7 @@ +import type BigNumber from 'bignumber.js'; import type { TFunction } from 'i18next'; -import { formatPercentageToReadableValue } from 'utilities'; +import { formatDistributionApyToReadableValue, formatPercentageToReadableValue } from 'utilities'; import type { ApyBreakdownItem } from '..'; import type { LabeledInlineContentProps } from '../../LabeledInlineContent'; import { ValueUpdate } from '../../ValueUpdate'; @@ -12,6 +13,9 @@ export const formatRows = ({ item: ApyBreakdownItem; t: TFunction<'translation', undefined>; }) => { + const formatDistributionApy = (apyPercentage: BigNumber) => + formatDistributionApyToReadableValue({ apyPercentage, type: item.type }); + const rows: LabeledInlineContentProps[] = [ { label: item.type === 'borrow' ? t('apyBreakdown.borrowApy') : t('apyBreakdown.supplyApy'), @@ -25,7 +29,15 @@ export const formatRows = ({ const distributionRows = item.tokenDistributions .filter(distribution => distribution.type !== 'primeSimulation' && distribution.isActive) .reduce((acc, distribution) => { - if (distribution.type !== 'prime' && distribution.apyPercentage.isEqualTo(0)) { + const collateralGate = + distribution.type === 'merkl' ? distribution.collateralGate : undefined; + const isMissingRequiredCollateral = !!collateralGate && !collateralGate.isUserEligible; + + if ( + distribution.type !== 'prime' && + distribution.apyPercentage.isEqualTo(0) && + !isMissingRequiredCollateral + ) { return acc; } @@ -67,15 +79,43 @@ export const formatRows = ({ children = ( + ); + } else if (isMissingRequiredCollateral) { + // Muted, as this rate is not part of the total until the user provides the collateral + children = ( + + {formatDistributionApy(collateralGate.maxApyPercentage)} + + ); + } else if (distribution.type === 'merkl' && collateralGate) { + // Position-dependent, so it moves with the simulated balances the way Prime APY does + const simulatedDistribution = item.simulatedTokenDistributions?.find( + simulated => + simulated.type === 'merkl' && + simulated.rewardDetails.merklCampaignIdentifier === + distribution.rewardDetails.merklCampaignIdentifier, + ); + + const hasMoved = + !!simulatedDistribution && + !simulatedDistribution.apyPercentage.isEqualTo(distribution.apyPercentage); + + children = ( + ); } else { - children = formatPercentageToReadableValue(distribution.apyPercentage); + children = formatDistributionApy(distribution.apyPercentage); } let tooltip = undefined; @@ -100,6 +140,12 @@ export const formatRows = ({ tooltip = t('apyBreakdown.liquidityHubIntrinsicApyTooltip'); } + if (isMissingRequiredCollateral) { + tooltip = t('apyBreakdown.collateralGatedMerklApyTooltip', { + tokenSymbol: item.token.symbol, + }); + } + const row: LabeledInlineContentProps = { label, iconSrc: distribution.token, diff --git a/apps/evm/src/containers/MarketForm/ApyBreakdown/__tests__/index.spec.tsx b/apps/evm/src/containers/MarketForm/ApyBreakdown/__tests__/index.spec.tsx index a6deec46d5..e918768b54 100644 --- a/apps/evm/src/containers/MarketForm/ApyBreakdown/__tests__/index.spec.tsx +++ b/apps/evm/src/containers/MarketForm/ApyBreakdown/__tests__/index.spec.tsx @@ -81,7 +81,7 @@ describe('ApyBreakdown', () => { ); expect(container.textContent).toBe( - 'Supply APY0.05%Distribution APY0.11%Borrow APY-4.97%Distribution APY0.52%Net APY7.66%', + 'Supply APY0.05%Distribution APY0.11%Borrow APY-4.97%Distribution APY-0.52%Net APY7.66%', ); }); @@ -100,12 +100,12 @@ describe('ApyBreakdown', () => { { label: 'borrow', balanceMutations: [fakeBalanceMutations[3]], - expectedTextContent: 'Borrow APY-4.97%Distribution APY0.52%Total borrow APY-6.49%', + expectedTextContent: 'Borrow APY-4.97%Distribution APY-0.52%Total borrow APY-6.49%', }, { label: 'repay', balanceMutations: [fakeBalanceMutations[4]], - expectedTextContent: 'Borrow APY-4.97%Distribution APY0.52%Total borrow APY-6.49%', + expectedTextContent: 'Borrow APY-4.97%Distribution APY-0.52%Total borrow APY-6.49%', }, ] satisfies { label: string; diff --git a/apps/evm/src/libs/translations/translations/en.json b/apps/evm/src/libs/translations/translations/en.json index 6b86147cee..ad8debc012 100644 --- a/apps/evm/src/libs/translations/translations/en.json +++ b/apps/evm/src/libs/translations/translations/en.json @@ -207,6 +207,9 @@ "description": "The accruing interest when borrowing this asset from the Venus Protocol.", "name": "Borrow APY" }, + "collateralGatedMerklReward": { + "description": "The maximum reward rate on this market. It applies only while your {{tokenSymbol}} borrow is collateralized by bStock, and your position is not eligible yet." + }, "defaultMerklRewardName": "Merkl rewards", "externalRewardDescription": "Rewards from this external program can be claimed through their official app. The Venus protocol does not guarantee them and accepts no liability.", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "Merkl campaign logo", + "tooltip": { + "leadLine": "Collateralize your borrow with bStock to bring your borrow APY to {{apy}}." + } + }, "primeBadge": { "logoAlt": "Prime logo", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "Borrow APY", + "collateralGatedMerklApyTooltip": "The maximum reward rate on this market. It applies only while your {{tokenSymbol}} borrow is collateralized by bStock, so it is not counted in your total yet.", "distributionApy": "Distribution APY", "distributionTooltip": "Distribution rewards are initiated and implemented by the decentralized Venus community. The Venus protocol does not guarantee them and accepts no liability.", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/libs/translations/translations/ja.json b/apps/evm/src/libs/translations/translations/ja.json index 87684d784b..d2c7eb86dc 100644 --- a/apps/evm/src/libs/translations/translations/ja.json +++ b/apps/evm/src/libs/translations/translations/ja.json @@ -207,6 +207,9 @@ "description": "Venusプロトコルからこの資産を借りる際に発生する利息です。", "name": "借入APY" }, + "collateralGatedMerklReward": { + "description": "このマーケットの最大報酬レートです。{{tokenSymbol}}の借入がbStockを担保としている場合にのみ適用され、現在のポジションはまだ対象外です。" + }, "defaultMerklRewardName": "Merkl報酬", "externalRewardDescription": "この外部プログラムからの報酬は、公式アプリで請求できます。Venusプロトコルはこれらを保証せず、責任を負いません。", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "Merklキャンペーンロゴ", + "tooltip": { + "leadLine": "bStockを担保にして借り入れると、借入APYが{{apy}}になります。" + } + }, "primeBadge": { "logoAlt": "Primeロゴ", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "借入APY", + "collateralGatedMerklApyTooltip": "このマーケットの最大報酬レートです。{{tokenSymbol}}の借入がbStockを担保としている場合にのみ適用されるため、まだ合計には含まれていません。", "distributionApy": "配布APY", "distributionTooltip": "配布報酬は分散型のVenusコミュニティにより開始・実装されます。Venusプロトコルはこれらを保証せず、責任を負いません。", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/libs/translations/translations/th.json b/apps/evm/src/libs/translations/translations/th.json index aa4359b426..e8c52f9cd2 100644 --- a/apps/evm/src/libs/translations/translations/th.json +++ b/apps/evm/src/libs/translations/translations/th.json @@ -207,6 +207,9 @@ "description": "ดอกเบี้ยที่เกิดขึ้นเมื่อยืมสินทรัพย์นี้จากโปรโตคอล Venus", "name": "APY การยืม" }, + "collateralGatedMerklReward": { + "description": "อัตรารางวัลสูงสุดในตลาดนี้ ใช้ได้เฉพาะเมื่อการกู้ยืม {{tokenSymbol}} ของคุณใช้ bStock เป็นหลักประกัน และสถานะของคุณยังไม่ผ่านเกณฑ์" + }, "defaultMerklRewardName": "รางวัล Merkl", "externalRewardDescription": "รางวัลจากโปรแกรมภายนอกนี้สามารถรับได้ผ่าน แอปอย่างเป็นทางการ โปรโตคอล Venus ไม่รับประกันและไม่รับผิดชอบ", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "โลโก้แคมเปญ Merkl", + "tooltip": { + "leadLine": "ใช้ bStock เป็นหลักประกันสำหรับการกู้ยืมของคุณ เพื่อให้ APY การกู้ยืมเป็น {{apy}}" + } + }, "primeBadge": { "logoAlt": "โลโก้ Prime", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "APY การยืม", + "collateralGatedMerklApyTooltip": "อัตรารางวัลสูงสุดในตลาดนี้ ใช้ได้เฉพาะเมื่อการกู้ยืม {{tokenSymbol}} ของคุณใช้ bStock เป็นหลักประกัน จึงยังไม่ถูกนับรวมในยอดรวมของคุณ", "distributionApy": "APY การแจกจ่าย", "distributionTooltip": "รางวัลการแจกจ่ายถูกเริ่มและดำเนินการโดยชุมชน Venus แบบกระจายอำนาจ โปรโตคอล Venus ไม่รับประกันและไม่รับผิดชอบ", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/libs/translations/translations/tr.json b/apps/evm/src/libs/translations/translations/tr.json index f3842fc390..e267579aed 100644 --- a/apps/evm/src/libs/translations/translations/tr.json +++ b/apps/evm/src/libs/translations/translations/tr.json @@ -207,6 +207,9 @@ "description": "Venus Protokolünden bu varlığı borç alırken biriken faiz.", "name": "Borç APY" }, + "collateralGatedMerklReward": { + "description": "Bu piyasadaki maksimum ödül oranı. Yalnızca {{tokenSymbol}} borcunuz bStock ile teminatlandırıldığında geçerlidir ve pozisyonunuz henüz uygun değil." + }, "defaultMerklRewardName": "Merkl ödülleri", "externalRewardDescription": "Bu dış programdaki ödüller resmi uygulaması üzerinden talep edilebilir. Venus protokolü bunları garanti etmez ve sorumluluk kabul etmez.", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "Merkl kampanya logosu", + "tooltip": { + "leadLine": "Borcunuzu bStock ile teminatlandırarak borç APY’nizi {{apy}} seviyesine getirin." + } + }, "primeBadge": { "logoAlt": "Prime logosu", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "Borç APY", + "collateralGatedMerklApyTooltip": "Bu piyasadaki maksimum ödül oranı. Yalnızca {{tokenSymbol}} borcunuz bStock ile teminatlandırıldığında geçerli olduğundan henüz toplamınıza dahil edilmez.", "distributionApy": "Dağıtım APY", "distributionTooltip": "Dağıtım ödülleri merkeziyetsiz Venus topluluğu tarafından başlatılır ve uygulanır. Venus protokolü bu ödülleri garanti etmez ve sorumluluk kabul etmez.", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/libs/translations/translations/vi.json b/apps/evm/src/libs/translations/translations/vi.json index ac1f1dc262..a264ad8288 100644 --- a/apps/evm/src/libs/translations/translations/vi.json +++ b/apps/evm/src/libs/translations/translations/vi.json @@ -207,6 +207,9 @@ "description": "Lãi tích lũy khi vay tài sản này từ Venus Protocol.", "name": "APY vay" }, + "collateralGatedMerklReward": { + "description": "Mức thưởng tối đa trên thị trường này. Nó chỉ áp dụng khi khoản vay {{tokenSymbol}} của bạn được thế chấp bằng bStock, và vị thế của bạn hiện chưa đủ điều kiện." + }, "defaultMerklRewardName": "Phần thưởng Merkl", "externalRewardDescription": "Phần thưởng từ chương trình bên ngoài này có thể được nhận qua ứng dụng chính thức của họ. Venus protocol không đảm bảo và không chịu trách nhiệm.", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "Logo chiến dịch Merkl", + "tooltip": { + "leadLine": "Thế chấp khoản vay của bạn bằng bStock để đưa APY vay về {{apy}}." + } + }, "primeBadge": { "logoAlt": "Logo Prime", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "APY vay", + "collateralGatedMerklApyTooltip": "Mức thưởng tối đa trên thị trường này. Nó chỉ áp dụng khi khoản vay {{tokenSymbol}} của bạn được thế chấp bằng bStock, nên chưa được tính vào tổng của bạn.", "distributionApy": "APY phân phối", "distributionTooltip": "Phần thưởng phân phối được khởi xướng và triển khai bởi cộng đồng Venus phi tập trung. Venus protocol không đảm bảo và không chịu trách nhiệm.", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/libs/translations/translations/zh-Hans.json b/apps/evm/src/libs/translations/translations/zh-Hans.json index f5007a0781..8e7cd306e2 100644 --- a/apps/evm/src/libs/translations/translations/zh-Hans.json +++ b/apps/evm/src/libs/translations/translations/zh-Hans.json @@ -207,6 +207,9 @@ "description": "在 Venus 协议中借入该资产时产生的利息。", "name": "借款 APY" }, + "collateralGatedMerklReward": { + "description": "该市场的最高奖励率。仅当你的 {{tokenSymbol}} 借款以 bStock 作为抵押时才适用,你当前的仓位尚不符合条件。" + }, "defaultMerklRewardName": "Merkl 奖励", "externalRewardDescription": "该外部计划的奖励可通过其 官方应用领取。Venus 协议不作任何保证且不承担责任。", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "Merkl 活动标志", + "tooltip": { + "leadLine": "使用 bStock 为你的借款提供抵押,即可将借款 APY 变为 {{apy}}。" + } + }, "primeBadge": { "logoAlt": "Prime 标志", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "借款 APY", + "collateralGatedMerklApyTooltip": "该市场的最高奖励率。仅当你的 {{tokenSymbol}} 借款以 bStock 作为抵押时才适用,因此尚未计入你的总计。", "distributionApy": "分发 APY", "distributionTooltip": "分发奖励由去中心化的 Venus 社区发起并实施。Venus 协议不作任何保证且不承担责任。", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/libs/translations/translations/zh-Hant.json b/apps/evm/src/libs/translations/translations/zh-Hant.json index 4aef1828e0..64ebe84a82 100644 --- a/apps/evm/src/libs/translations/translations/zh-Hant.json +++ b/apps/evm/src/libs/translations/translations/zh-Hant.json @@ -207,6 +207,9 @@ "description": "在 Venus 協議中借入該資產時產生的利息。", "name": "借款 APY" }, + "collateralGatedMerklReward": { + "description": "該市場的最高獎勵率。僅當你的 {{tokenSymbol}} 借款以 bStock 作為抵押時才適用,你當前的倉位尚不符合條件。" + }, "defaultMerklRewardName": "Merkl 獎勵", "externalRewardDescription": "該外部計劃的獎勵可通過其 官方應用領取。Venus 協議不作任何保證且不承擔責任。", "intrinsicApy": { @@ -242,6 +245,12 @@ } } }, + "merklBadge": { + "logoAlt": "Merkl 活動標誌", + "tooltip": { + "leadLine": "使用 bStock 為你的借款提供抵押,即可將借款 APY 變為 {{apy}}。" + } + }, "primeBadge": { "logoAlt": "Prime 標誌", "tooltip": { @@ -255,6 +264,7 @@ }, "apyBreakdown": { "borrowApy": "借款 APY", + "collateralGatedMerklApyTooltip": "該市場的最高獎勵率。僅當你的 {{tokenSymbol}} 借款以 bStock 作為抵押時才適用,因此尚未計入你的總計。", "distributionApy": "分發 APY", "distributionTooltip": "分發獎勵由去中心化的 Venus 社區發起並實施。Venus 協議不作任何保證且不承擔責任。", "externalDistributionApy": "{{description}} APY", diff --git a/apps/evm/src/types/index.ts b/apps/evm/src/types/index.ts index 5b1672dab3..bd3ceaa257 100644 --- a/apps/evm/src/types/index.ts +++ b/apps/evm/src/types/index.ts @@ -86,6 +86,10 @@ export interface MerklDistribution { apyPercentage: BigNumber; dailyDistributedTokens: BigNumber; isActive: boolean; + collateralGate?: { + isUserEligible: boolean; + maxApyPercentage: BigNumber; + }; rewardDetails: { appName: string; claimUrl: string; @@ -93,6 +97,10 @@ export interface MerklDistribution { merklCampaignIdentifier: string; description: string; tags: string[]; + aprPercentage?: number; + // Only served for collateral-gated campaigns + participatingCollateralAddresses?: Address[]; + eligibleBorrowMarketAddresses?: Address[]; }; } @@ -960,6 +968,10 @@ export interface ApiMerklReward extends ApiReward { description: string; merklCampaignIdentifier: string; tags: string[]; + apr?: number; + // Only served for collateral-gated campaigns + participatingCollateralAddresses?: Address[]; + eligibleBorrowMarketAddresses?: Address[]; }; } diff --git a/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/formatRewardDistribution/index.ts b/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/formatRewardDistribution/index.ts index 6b812bca29..20a67e658e 100644 --- a/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/formatRewardDistribution/index.ts +++ b/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/formatRewardDistribution/index.ts @@ -17,6 +17,9 @@ interface MerklRewardDetails { description: string; claimUrl: string; tags: string[]; + apr?: number; + participatingCollateralAddresses?: Address[]; + eligibleBorrowMarketAddresses?: Address[]; } interface GenericDistributionRewardDetails { @@ -54,11 +57,13 @@ export const formatRewardDistribution = ({ }; if (rewardType === 'merkl' && rewardDetails) { + const { apr, ...merklRewardDetails } = rewardDetails as MerklRewardDetails; + const distribution: MerklDistribution = { ...baseProps, type: 'merkl', isActive, - rewardDetails: { ...(rewardDetails as MerklRewardDetails), marketAddress }, + rewardDetails: { ...merklRewardDetails, marketAddress, aprPercentage: apr }, }; return distribution; diff --git a/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/index.ts b/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/index.ts index 2c08448398..46791feffb 100644 --- a/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/index.ts +++ b/apps/evm/src/utilities/formatApiRewardDistributors/formatRewardTokenDistribution/index.ts @@ -8,6 +8,7 @@ import { isDistributingRewards } from './isDistributingRewards'; interface FormatRewardTokenDistributionInput { isActive: boolean; isTimeBasedOrMerklReward: boolean; + isCollateralGatedCampaign?: boolean; lastRewardingBlockOrTimestamp: string; currentBlockNumber?: bigint; rateMantissa: string; @@ -22,6 +23,7 @@ interface FormatRewardTokenDistributionInput { export const formatRewardTokenDistribution = ({ isActive, isTimeBasedOrMerklReward, + isCollateralGatedCampaign = false, lastRewardingBlockOrTimestamp, currentBlockNumber, rateMantissa, @@ -32,7 +34,7 @@ export const formatRewardTokenDistribution = ({ rewardDetails, apyPercentage, }: FormatRewardTokenDistributionInput): TokenDistribution | undefined => { - const isReward = Number(rateMantissa) > 0; + const isReward = isCollateralGatedCampaign || Number(rateMantissa) > 0; if (!isReward) { return undefined; diff --git a/apps/evm/src/utilities/formatApiRewardDistributors/index.ts b/apps/evm/src/utilities/formatApiRewardDistributors/index.ts index 94f1ad35a5..1bd90d237b 100644 --- a/apps/evm/src/utilities/formatApiRewardDistributors/index.ts +++ b/apps/evm/src/utilities/formatApiRewardDistributors/index.ts @@ -1,3 +1,4 @@ +import BigNumber from 'bignumber.js'; import type { ApiRewardDistributor, Token, TokenDistribution } from 'types'; import { convertRatioToPercentage } from 'utilities/convertRatioToPercentage'; import findTokenByAddress from 'utilities/findTokenByAddress'; @@ -49,6 +50,17 @@ export const formatApiRewardDistributors = ({ const isChainTimeBased = !blocksPerDay; const isTimeBasedOrMerklReward = isChainTimeBased || rewardType === 'merkl'; + + // Collateral-gated Merkl campaigns distribute no supply or borrow speed. Merkl reports a + // campaign-wide APR instead, which is then refined per user based on their positions + // Both address lists are required: without the collateral list no user could ever qualify + const merklRewardDetails = rewardType === 'merkl' ? rewardDetails : undefined; + const collateralGatedCampaignAprPercentage = + merklRewardDetails?.eligibleBorrowMarketAddresses?.length && + merklRewardDetails.participatingCollateralAddresses?.length + ? new BigNumber(merklRewardDetails.apr ?? 0) + : undefined; + const rewardTokenDistributionInput = { isActive, isTimeBasedOrMerklReward, @@ -73,9 +85,11 @@ export const formatApiRewardDistributors = ({ const borrowTokenDistribution = formatRewardTokenDistribution({ ...rewardTokenDistributionInput, + isCollateralGatedCampaign: !!collateralGatedCampaignAprPercentage, lastRewardingBlockOrTimestamp: lastRewardingBorrowBlockOrTimestamp, rateMantissa: borrowSpeed, - apyPercentage: convertRatioToPercentage(borrowApyRatio), + apyPercentage: + collateralGatedCampaignAprPercentage ?? convertRatioToPercentage(borrowApyRatio), }); if (borrowTokenDistribution) { diff --git a/apps/evm/src/utilities/formatDistributionApyToReadableValue/index.ts b/apps/evm/src/utilities/formatDistributionApyToReadableValue/index.ts new file mode 100644 index 0000000000..29b4e01d98 --- /dev/null +++ b/apps/evm/src/utilities/formatDistributionApyToReadableValue/index.ts @@ -0,0 +1,12 @@ +import type BigNumber from 'bignumber.js'; + +import formatPercentageToReadableValue from 'utilities/formatPercentageToReadableValue'; + +// Rewards lower the rate a borrower pays, so they read as negative on the borrow side +export const formatDistributionApyToReadableValue = ({ + apyPercentage, + type, +}: { + apyPercentage: BigNumber; + type: 'supply' | 'borrow'; +}) => formatPercentageToReadableValue(type === 'borrow' ? apyPercentage.negated() : apyPercentage); diff --git a/apps/evm/src/utilities/index.ts b/apps/evm/src/utilities/index.ts index 9085e62fbc..33193b1e87 100755 --- a/apps/evm/src/utilities/index.ts +++ b/apps/evm/src/utilities/index.ts @@ -3,6 +3,7 @@ export { default as scrollToElement } from './scrollToElement'; export { default as shortenValueWithSuffix } from './shortenValueWithSuffix'; export { default as formatCentsToReadableValue } from './formatCentsToReadableValue'; export { default as formatPercentageToReadableValue } from './formatPercentageToReadableValue'; +export { formatDistributionApyToReadableValue } from './formatDistributionApyToReadableValue'; export { default as convertTokensToMantissa } from './convertTokensToMantissa'; export { default as indexBy } from './indexBy'; export { default as notUndefined } from './notUndefined';