Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/olive-pandas-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@venusprotocol/evm": minor
---

display the per-user reward APY of collateral-gated Merkl borrow campaigns
5 changes: 5 additions & 0 deletions apps/evm/src/clients/api/queries/getSimulatedPool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
calculateYearlyEarningsForAssets,
clampToZero,
} from 'utilities';
import { withMerklCollateralGates } from '../useGetPools/useGetPoolsQuery/getPools/withMerklCollateralGates';
import { addUserPrimeApys } from './addUserPrimeApys';

export interface GetSimulatedPoolInput {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ApiTokenMetadata>;
Expand Down Expand Up @@ -197,7 +198,7 @@ export const getPools = async ({
userLegacyPoolEModeGroupId;
}

const pools = formatOutput({
const formattedPools = formatOutput({
chainId,
isUserConnected: !!accountAddress,
tokens,
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading